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:
210
docs/commenting-standard.md
Normal file
210
docs/commenting-standard.md
Normal 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 ...
|
||||||
|
```
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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 ===')
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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 ==="
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
# Backend package
|
"""Package principal du backend FastAPI bot-detector."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
# Routes package
|
"""Package des routes FastAPI de l'API bot-detector."""
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user