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