From 3dfeba860b55db7af905b240f78e81316739bbf2 Mon Sep 17 00:00:00 2001 From: toto Date: Tue, 7 Apr 2026 21:32:29 +0200 Subject: [PATCH] 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> --- docs/commenting-standard.md | 210 ++++++++++++++++++ services/bot-detector/anubis/fetch_rules.py | 13 ++ .../bot-detector/bot_detector/bot_detector.py | 68 +++++- services/correlator/cmd/logcorrelator/main.go | 2 + .../adapters/inbound/unixsocket/source.go | 6 + .../correlator/internal/app/orchestrator.go | 1 + .../internal/domain/correlated_log.go | 4 + .../internal/domain/correlation_service.go | 10 + .../internal/observability/logger.go | 4 +- .../correlator/scripts/audit-architecture.sh | 19 +- services/dashboard/backend/__init__.py | 2 +- services/dashboard/backend/config.py | 2 + services/dashboard/backend/models.py | 17 ++ services/dashboard/backend/routes/__init__.py | 2 +- services/dashboard/backend/routes/analysis.py | 1 + services/dashboard/backend/routes/botnets.py | 1 + .../dashboard/backend/routes/clustering.py | 2 + .../dashboard/backend/routes/fingerprints.py | 1 + services/dashboard/backend/routes/metrics.py | 1 + .../dashboard/backend/routes/ml_features.py | 2 + .../ja4_common/ja4_common/clickhouse.py | 17 +- .../python/ja4_common/ja4_common/settings.py | 13 +- 22 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 docs/commenting-standard.md diff --git a/docs/commenting-standard.md b/docs/commenting-standard.md new file mode 100644 index 0000000..7ea92d2 --- /dev/null +++ b/docs/commenting-standard.md @@ -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 ... +``` diff --git a/services/bot-detector/anubis/fetch_rules.py b/services/bot-detector/anubis/fetch_rules.py index 53271fb..17c4722 100644 --- a/services/bot-detector/anubis/fetch_rules.py +++ b/services/bot-detector/anubis/fetch_rules.py @@ -72,6 +72,7 @@ UA_PARENT_OVERRIDE: dict[str, str] = {} # ────────────────────────────────────────────────────────────────────────────── 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: with urllib.request.urlopen(url, timeout=timeout) as resp: 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: + """Télécharge et désérialise un fichier YAML depuis une URL. Retourne None si inaccessible.""" content = _fetch_url(url) if content: return yaml.safe_load(content) @@ -334,6 +336,7 @@ def collect_all_rules() -> tuple[list, list, list, list]: # ────────────────────────────────────────────────────────────────────────────── def get_ch_client(): + """Crée et retourne un client ClickHouse configuré depuis les variables d'environnement.""" return clickhouse_connect.get_client( host=os.environ.get("CLICKHOUSE_HOST", "clickhouse"), 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: + """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: print("[INFO] Aucune règle UA.") return @@ -366,6 +373,7 @@ def insert_ua_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: print("[INFO] Aucune règle IP.") return @@ -381,6 +389,7 @@ def insert_ip_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: print("[INFO] Aucune règle ASN.") return @@ -392,6 +401,7 @@ def insert_asn_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: print("[INFO] Aucune règle pays.") return @@ -403,6 +413,7 @@ def insert_country_rules(client, rules: list[dict]) -> None: def reload_dicts(client) -> None: + """Recharge les quatre dictionnaires ClickHouse Anubis après mise à jour des tables sources.""" dicts = [ f"{DB_PROC}.dict_anubis_ua", 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): + """Affiche un résumé lisible des règles collectées (UA, IP, ASN, pays) sur la sortie standard.""" print("\n── Règles UA ──") by_cat: dict[str, list] = {} for r in ua_rules: @@ -460,6 +472,7 @@ def print_summary(ua_rules, ip_rules, asn_rules, country_rules): # ────────────────────────────────────────────────────────────────────────────── 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…") ua_rules, ip_rules, asn_rules, country_rules = collect_all_rules() diff --git a/services/bot-detector/bot_detector/bot_detector.py b/services/bot-detector/bot_detector/bot_detector.py index 8036cc3..8841b6c 100644 --- a/services/bot-detector/bot_detector/bot_detector.py +++ b/services/bot-detector/bot_detector/bot_detector.py @@ -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 os import json @@ -30,6 +40,10 @@ warnings.filterwarnings('ignore') # CONFIGURATION # ═══════════════════════════════════════════════════════════════════════════════ 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)) try: 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). def log_info(message: str): + """Enregistre un message de niveau INFO dans le logger du service.""" logger.info(message) 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 = { 'ts': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), '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() 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: 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 # ═══════════════════════════════════════════════════════════════════════════════ 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_decision('SERVICE_STOP', 'shutdown', '', {'signal': sig}) sys.exit(0) @@ -152,12 +174,20 @@ signal.signal(signal.SIGINT, _shutdown) _service_healthy = True 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): + """Répond à la requête GET : renvoie 200 OK ou 503 DEGRADED selon l'état du service.""" code = 200 if _service_healthy else 503 self.send_response(code) self.end_headers() 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( target=lambda: HTTPServer(('', HEALTH_PORT), _HealthHandler).serve_forever(), @@ -174,7 +204,10 @@ def get_client(): return _ja4_get_client().connect() 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.15: return 'HIGH' if score < -0.05: return 'MEDIUM' @@ -185,9 +218,11 @@ def score_to_threat_level(score: float) -> str: # GESTION DES MODÈLES # ═══════════════════════════════════════════════════════════════════════════════ 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') 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) if not os.path.exists(pointer): return None, None with open(pointer) as f: version_id = f.read().strip() @@ -198,6 +233,7 @@ def _get_current_version(name: str): return model_path, meta 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') versions = sorted(glob.glob(pattern)) 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})") 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) if model_path and meta: 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 # ═══════════════════════════════════════════════════════════════════════════════ 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 known_bots = 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 recent_map = dict(zip(recent_df['src_ip'], recent_df['best_score'])) 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']) if prev is None: return True @@ -712,7 +766,13 @@ def _preprocess_df(df: pd.DataFrame) -> pd.DataFrame: # ═══════════════════════════════════════════════════════════════════════════════ _consecutive_failures = 0 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') log_info('=== Lancement cycle IA ===') diff --git a/services/correlator/cmd/logcorrelator/main.go b/services/correlator/cmd/logcorrelator/main.go index 8a45794..ed507c7 100644 --- a/services/correlator/cmd/logcorrelator/main.go +++ b/services/correlator/cmd/logcorrelator/main.go @@ -1,3 +1,4 @@ +// Package main initialise et démarre le service logcorrelator. package main import ( @@ -23,6 +24,7 @@ import ( var Version = "dev" +// main configure les sources, les puits et le service de corrélation, puis démarre l'orchestrateur. func main() { configPath := flag.String("config", "config.yml", "path to configuration file") version := flag.Bool("version", false, "print version and exit") diff --git a/services/correlator/internal/adapters/inbound/unixsocket/source.go b/services/correlator/internal/adapters/inbound/unixsocket/source.go index 1fb631d..55f663a 100644 --- a/services/correlator/internal/adapters/inbound/unixsocket/source.go +++ b/services/correlator/internal/adapters/inbound/unixsocket/source.go @@ -117,6 +117,7 @@ func (s *UnixSocketSource) Start(ctx context.Context, eventChan chan<- *domain.N 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) { 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 { switch strings.ToLower(strings.TrimSpace(sourceType)) { 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) { var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { @@ -298,6 +301,7 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er 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) { if v, ok := m[key]; ok { if s, ok := v.(string); ok { @@ -307,6 +311,7 @@ func getString(m map[string]any, key string) (string, bool) { 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) { if v, ok := m[key]; ok { switch val := v.(type) { @@ -328,6 +333,7 @@ func getInt(m map[string]any, key string) (int, bool) { 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) { if v, ok := m[key]; ok { switch val := v.(type) { diff --git a/services/correlator/internal/app/orchestrator.go b/services/correlator/internal/app/orchestrator.go index 4361477..78134b2 100644 --- a/services/correlator/internal/app/orchestrator.go +++ b/services/correlator/internal/app/orchestrator.go @@ -103,6 +103,7 @@ func (o *Orchestrator) Start() error { 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) { for { select { diff --git a/services/correlator/internal/domain/correlated_log.go b/services/correlator/internal/domain/correlated_log.go index a54d1cd..0a94476 100644 --- a/services/correlator/internal/domain/correlated_log.go +++ b/services/correlator/internal/domain/correlated_log.go @@ -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 { result := make(map[string]any) for k, v := range e.Raw { @@ -109,6 +110,7 @@ func extractFields(e *NormalizedEvent) map[string]any { 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 { result := make(map[string]any) @@ -136,6 +138,7 @@ func mergeFields(a, b *NormalizedEvent) map[string]any { return result } +// coalesceString retourne la première chaîne non vide parmi les deux arguments. func coalesceString(a, b string) string { if a != "" { return a @@ -143,6 +146,7 @@ func coalesceString(a, b string) string { return b } +// coalesceInt retourne le premier entier non nul parmi les deux arguments. func coalesceInt(a, b int) int { if a != 0 { return a diff --git a/services/correlator/internal/domain/correlation_service.go b/services/correlator/internal/domain/correlation_service.go index 2efbb7f..0f3406b 100644 --- a/services/correlator/internal/domain/correlation_service.go +++ b/services/correlator/internal/domain/correlation_service.go @@ -74,6 +74,7 @@ type eventBuffer struct { events *list.List } +// newEventBuffer crée un nouveau tampon d'événements vide basé sur une liste doublement chaînée. func newEventBuffer() *eventBuffer { return &eventBuffer{ events: list.New(), @@ -288,6 +289,7 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo return results } +// getBufferSize retourne la taille actuelle du tampon correspondant à la source donnée. func (s *CorrelationService) getBufferSize(source EventSource) int { switch source { case SourceA: @@ -298,6 +300,7 @@ func (s *CorrelationService) getBufferSize(source EventSource) int { 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 { switch source { case SourceA: @@ -355,6 +358,7 @@ func (s *CorrelationService) rotateOldestB() { 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) { key := event.CorrelationKey() // Assign Keep-Alive sequence number (1-based) for this connection @@ -457,6 +461,7 @@ func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]Correlate 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) { key := event.CorrelationKey() 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 } +// 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 { diff := a.Timestamp.Sub(b.Timestamp) if diff < 0 { @@ -536,6 +542,7 @@ func (s *CorrelationService) bEventHasValidTTL(bEvent *NormalizedEvent) bool { 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) { 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 { // Clean expired B events first - use TTL map only (not event timestamp) // This is critical for Keep-Alive: TTL is reset on each correlation, @@ -693,6 +701,7 @@ func (s *CorrelationService) cleanNetworkBufferByTTL() []CorrelatedLog { return forced } +// findAndPopFirstMatch recherche et supprime le premier événement satisfaisant le critère dans le tampon. func (s *CorrelationService) findAndPopFirstMatch( buffer *eventBuffer, pending map[string][]*list.Element, @@ -908,6 +917,7 @@ func (s *CorrelationService) EmitPendingOrphans() []CorrelatedLog { 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 { if len(elements) == 0 { return elements diff --git a/services/correlator/internal/observability/logger.go b/services/correlator/internal/observability/logger.go index cc8a282..2f21753 100644 --- a/services/correlator/internal/observability/logger.go +++ b/services/correlator/internal/observability/logger.go @@ -4,8 +4,10 @@ package observability 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 + +// LogLevel est un alias du type LogLevel de ja4common pour le niveau de journalisation. type LogLevel = jalogger.LogLevel const ( diff --git a/services/correlator/scripts/audit-architecture.sh b/services/correlator/scripts/audit-architecture.sh index a315e95..26e58c2 100755 --- a/services/correlator/scripts/audit-architecture.sh +++ b/services/correlator/scripts/audit-architecture.sh @@ -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 /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 echo "=== AUDIT ARCHITECTURE COMPLIANCE ===" diff --git a/services/dashboard/backend/__init__.py b/services/dashboard/backend/__init__.py index 7f83169..e022edf 100644 --- a/services/dashboard/backend/__init__.py +++ b/services/dashboard/backend/__init__.py @@ -1 +1 @@ -# Backend package +"""Package principal du backend FastAPI bot-detector.""" diff --git a/services/dashboard/backend/config.py b/services/dashboard/backend/config.py index b057947..519a183 100644 --- a/services/dashboard/backend/config.py +++ b/services/dashboard/backend/config.py @@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): + """Paramètres de configuration de l'application chargés depuis l'environnement.""" # ClickHouse CLICKHOUSE_HOST: str = "clickhouse" CLICKHOUSE_PORT: int = 8123 @@ -22,6 +23,7 @@ class Settings(BaseSettings): CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"] class Config: + """Configuration Pydantic pour le chargement du fichier .env.""" env_file = ".env" case_sensitive = True diff --git a/services/dashboard/backend/models.py b/services/dashboard/backend/models.py index d62a9f9..8fbf1de 100644 --- a/services/dashboard/backend/models.py +++ b/services/dashboard/backend/models.py @@ -8,6 +8,7 @@ from enum import Enum class ThreatLevel(str, Enum): + """Niveaux de menace supportés par le modèle de détection.""" CRITICAL = "CRITICAL" HIGH = "HIGH" MEDIUM = "MEDIUM" @@ -19,6 +20,7 @@ class ThreatLevel(str, Enum): # ───────────────────────────────────────────────────────────────────────────── class MetricsSummary(BaseModel): + """Résumé agrégé des métriques sur les dernières 24 heures.""" total_detections: int critical_count: int high_count: int @@ -30,6 +32,7 @@ class MetricsSummary(BaseModel): class TimeSeriesPoint(BaseModel): + """Point de série temporelle par heure pour les métriques.""" hour: datetime total: int critical: int @@ -39,6 +42,7 @@ class TimeSeriesPoint(BaseModel): class MetricsResponse(BaseModel): + """Réponse complète des métriques du dashboard avec série temporelle.""" summary: MetricsSummary timeseries: List[TimeSeriesPoint] threat_distribution: Dict[str, int] @@ -49,6 +53,7 @@ class MetricsResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── class Detection(BaseModel): + """Représentation d'une détection d'anomalie émise par le modèle ML.""" detected_at: datetime src_ip: str ja4: str @@ -82,6 +87,7 @@ class Detection(BaseModel): class DetectionsListResponse(BaseModel): + """Liste paginée de détections d'anomalies.""" items: List[Detection] total: int page: int @@ -94,6 +100,7 @@ class DetectionsListResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── class AttributeValue(BaseModel): + """Valeur d'attribut avec comptage, pourcentage et métadonnées temporelles.""" value: str count: int percentage: float @@ -105,6 +112,7 @@ class AttributeValue(BaseModel): class VariabilityAttributes(BaseModel): + """Ensemble des attributs de variabilité comportementale pour une entité.""" user_agents: List[AttributeValue] = Field(default_factory=list) ja4: List[AttributeValue] = Field(default_factory=list) countries: List[AttributeValue] = Field(default_factory=list) @@ -115,11 +123,13 @@ class VariabilityAttributes(BaseModel): class Insight(BaseModel): + """Message d'analyse contextuelle (alerte, information ou succès).""" type: str # "warning", "info", "success" message: str class VariabilityResponse(BaseModel): + """Réponse d'analyse de variabilité pour un attribut donné.""" type: str value: str total_detections: int @@ -134,11 +144,13 @@ class VariabilityResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── class AttributeListItem(BaseModel): + """Élément de la liste des valeurs uniques d'un attribut avec son comptage.""" value: str count: int class AttributeListResponse(BaseModel): + """Réponse de la liste des valeurs uniques pour un type d'attribut.""" type: str items: List[AttributeListItem] total: int @@ -149,6 +161,7 @@ class AttributeListResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── class UserAgentValue(BaseModel): + """Valeur de User-Agent avec comptage et plage temporelle d'observation.""" value: str count: int percentage: float @@ -157,6 +170,7 @@ class UserAgentValue(BaseModel): class UserAgentsResponse(BaseModel): + """Réponse de la liste des User-Agents associés à une entité.""" type: str value: str user_agents: List[UserAgentValue] @@ -169,12 +183,14 @@ class UserAgentsResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── class ClassificationLabel(str, Enum): + """Étiquettes de classification SOC pour les IPs et fingerprints JA4.""" LEGITIMATE = "legitimate" SUSPICIOUS = "suspicious" MALICIOUS = "malicious" class ClassificationBase(BaseModel): + """Modèle de base partagé pour les classifications SOC.""" ip: Optional[str] = None ja4: Optional[str] = None label: ClassificationLabel @@ -198,6 +214,7 @@ class Classification(ClassificationBase): class ClassificationsListResponse(BaseModel): + """Liste paginée des classifications SOC enregistrées.""" items: List[Classification] total: int diff --git a/services/dashboard/backend/routes/__init__.py b/services/dashboard/backend/routes/__init__.py index d212dab..485e5c1 100644 --- a/services/dashboard/backend/routes/__init__.py +++ b/services/dashboard/backend/routes/__init__.py @@ -1 +1 @@ -# Routes package +"""Package des routes FastAPI de l'API bot-detector.""" diff --git a/services/dashboard/backend/routes/analysis.py b/services/dashboard/backend/routes/analysis.py index bd23bbb..0565543 100644 --- a/services/dashboard/backend/routes/analysis.py +++ b/services/dashboard/backend/routes/analysis.py @@ -374,6 +374,7 @@ async def analyze_user_agents(ip: str): # Classification des UAs def classify_ua(ua: str) -> str: + """Classe un User-Agent en 'bot', 'script', 'browser' ou 'unknown'.""" ua_lower = ua.lower() if any(bot in ua_lower for bot in ['bot', 'crawler', 'spider', 'curl', 'wget', 'python', 'requests', 'scrapy']): return 'bot' diff --git a/services/dashboard/backend/routes/botnets.py b/services/dashboard/backend/routes/botnets.py index 292104e..08e4239 100644 --- a/services/dashboard/backend/routes/botnets.py +++ b/services/dashboard/backend/routes/botnets.py @@ -10,6 +10,7 @@ router = APIRouter(prefix="/api/botnets", tags=["botnets"]) def _botnet_class(unique_countries: int) -> str: + """Classifie un JA4 selon sa dispersion géographique.""" if unique_countries > 100: return "global_botnet" if unique_countries > 20: diff --git a/services/dashboard/backend/routes/clustering.py b/services/dashboard/backend/routes/clustering.py index f2da51f..6b9679d 100644 --- a/services/dashboard/backend/routes/clustering.py +++ b/services/dashboard/backend/routes/clustering.py @@ -222,6 +222,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None: continue 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])) 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")] 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] radar = [ diff --git a/services/dashboard/backend/routes/fingerprints.py b/services/dashboard/backend/routes/fingerprints.py index e501205..93baba2 100644 --- a/services/dashboard/backend/routes/fingerprints.py +++ b/services/dashboard/backend/routes/fingerprints.py @@ -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: + """Construit la liste des indicateurs de risque pour un User-Agent.""" flags = [] if ua_type == "bot": flags.append("ua_bot_signature") diff --git a/services/dashboard/backend/routes/metrics.py b/services/dashboard/backend/routes/metrics.py index f151094..8921782 100644 --- a/services/dashboard/backend/routes/metrics.py +++ b/services/dashboard/backend/routes/metrics.py @@ -144,6 +144,7 @@ async def get_metrics_baseline(): row = r.result_rows[0] if r.result_rows else None 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: return 100.0 if today > 0 else 0.0 return round((today - yesterday) / yesterday * 100, 1) diff --git a/services/dashboard/backend/routes/ml_features.py b/services/dashboard/backend/routes/ml_features.py index 9e826e1..8504d06 100644 --- a/services/dashboard/backend/routes/ml_features.py +++ b/services/dashboard/backend/routes/ml_features.py @@ -11,6 +11,7 @@ router = APIRouter(prefix="/api/ml", tags=["ml_features"]) def _attack_type(fuzzing_index: float, hit_velocity: float, is_fake_nav: int, ua_ch_mismatch: int) -> str: + """Déduit le type d'attaque depuis les métriques comportementales.""" if fuzzing_index > 50: return "brute_force" if hit_velocity > 1.0: @@ -113,6 +114,7 @@ async def get_ip_radar(ip: str): row = result.result_rows[0] def _f(v) -> float: + """Convertit une valeur nullable en float (None ou falsy → 0.0).""" return float(v or 0) return { diff --git a/shared/python/ja4_common/ja4_common/clickhouse.py b/shared/python/ja4_common/ja4_common/clickhouse.py index e11bc53..f29b9f6 100644 --- a/shared/python/ja4_common/ja4_common/clickhouse.py +++ b/shared/python/ja4_common/ja4_common/clickhouse.py @@ -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 from typing import Optional @@ -6,10 +6,19 @@ from .settings import settings 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): + """Initialise le client sans ouvrir de connexion immédiate.""" self._client: Optional[clickhouse_connect.driver.client.Client] = None 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(): self._client = clickhouse_connect.get_client( host=settings.CLICKHOUSE_HOST, @@ -22,6 +31,7 @@ class ClickHouseClient: return self._client def _ping(self) -> bool: + """Vérifie que la connexion existante est active. Retourne False en cas d'erreur.""" try: if self._client: self._client.ping() @@ -31,15 +41,19 @@ class ClickHouseClient: return False 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) 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) 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) def close(self): + """Ferme la connexion et réinitialise le client interne.""" if self._client: self._client.close() self._client = None @@ -49,6 +63,7 @@ _client: Optional[ClickHouseClient] = None def get_client() -> ClickHouseClient: + """Retourne l'instance singleton du ClickHouseClient, en la créant si nécessaire.""" global _client if _client is None: _client = ClickHouseClient() diff --git a/shared/python/ja4_common/ja4_common/settings.py b/shared/python/ja4_common/ja4_common/settings.py index 0f1904b..4ecf8e3 100644 --- a/shared/python/ja4_common/ja4_common/settings.py +++ b/shared/python/ja4_common/ja4_common/settings.py @@ -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 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_PORT: int = 8123 CLICKHOUSE_DB: str = "ja4_processing" # default connection database