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

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

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

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

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

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

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

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

View File

@ -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()

View File

@ -1,3 +1,13 @@
"""Détecteur de bots par apprentissage automatique semi-supervisé (IsolationForest).
Ce module implémente le cycle de détection IA du service bot_detector :
- chargement et retraining automatique du modèle IsolationForest,
- scoring, normalisation et classification du trafic (fenêtre 1h / 24h),
- intégration des règles Anubis (ALLOW / DENY / WEIGH),
- clustering comportemental DBSCAN, déduplication inter-cycles,
- explainabilité SHAP, détection de dérive conceptuelle,
- écriture des résultats dans ClickHouse (ml_detected_anomalies, ml_all_scores).
"""
import time
import 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 ===')

View File

@ -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")

View File

@ -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) {

View File

@ -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 {

View File

@ -101,6 +101,7 @@ func NewCorrelatedLog(apacheEvent, networkEvent *NormalizedEvent) CorrelatedLog
}
}
// extractFields copie l'ensemble des champs bruts d'un événement dans une nouvelle map.
func extractFields(e *NormalizedEvent) map[string]any {
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

View File

@ -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

View File

@ -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 (

View File

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

View File

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

View File

@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""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

View File

@ -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

View File

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

View File

@ -374,6 +374,7 @@ async def analyze_user_agents(ip: str):
# Classification des UAs
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'

View File

@ -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:

View File

@ -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 = [

View File

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

View File

@ -144,6 +144,7 @@ async def get_metrics_baseline():
row = r.result_rows[0] if r.result_rows else None
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)

View File

@ -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 {