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:
@ -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 ===')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user