diff --git a/services/dashboard/backend/routes/api.py b/services/dashboard/backend/routes/api.py
index ab684c8..6ebb03a 100644
--- a/services/dashboard/backend/routes/api.py
+++ b/services/dashboard/backend/routes/api.py
@@ -35,7 +35,7 @@ _SCORE_SORT_COLS = {
"asn_org", "country_code", "browser_family",
}
_TRAFFIC_SORT_COLS = {
- "time", "src_ip", "method", "host", "path", "http_version",
+ "time", "src_ip", "method", "status", "host", "path", "http_version",
"header_user_agent", "ja4", "src_country_code",
}
_ORDER_VALUES = {"ASC", "DESC"}
@@ -315,6 +315,8 @@ async def traffic(
method: str | None = Query(None),
host: str | None = Query(None),
http_version: str | None = Query(None),
+ status: int | None = Query(None),
+ search: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _TRAFFIC_SORT_COLS, "time")
order = _validate_order(order)
@@ -335,6 +337,18 @@ async def traffic(
where_clauses.append("http_version = {http_version:String}")
params["http_version"] = http_version
+ if status is not None:
+ where_clauses.append("status = {status:UInt16}")
+ params["status"] = status
+
+ if search:
+ where_clauses.append(
+ "(toString(src_ip) LIKE {search:String} "
+ "OR path LIKE {search:String} "
+ "OR header_user_agent LIKE {search:String})"
+ )
+ params["search"] = f"%{search}%"
+
where = " AND ".join(where_clauses)
try:
@@ -344,7 +358,7 @@ async def traffic(
)
rows = query(
- f"SELECT time, toString(src_ip) AS src_ip, method, host, path, "
+ f"SELECT time, toString(src_ip) AS src_ip, method, status, host, path, "
f"http_version, header_user_agent, ja4, src_country_code "
f"FROM {_DB_LOGS}.http_logs "
f"WHERE {where} ORDER BY {sort} {order} "
@@ -392,7 +406,7 @@ async def ip_detail(ip: str) -> dict[str, Any]:
)
http_logs = query(
- f"SELECT time, method, host, path, http_version, header_user_agent, ja4 "
+ f"SELECT time, method, status, host, path, http_version, header_user_agent, ja4 "
f"FROM {_DB_LOGS}.http_logs "
"WHERE src_ip = toIPv4OrZero({ip:String}) "
"AND time >= now() - INTERVAL 1 DAY "
@@ -739,6 +753,98 @@ async def models() -> dict[str, Any]:
return {"models": model_info, "scoring_stats": scoring_stats}
+# ---------------------------------------------------------------------------
+# GET /api/models/timeline — Scoring volume over time per model
+# ---------------------------------------------------------------------------
+@router.get("/models/timeline")
+async def models_timeline() -> dict[str, Any]:
+ """Volume de scoring horaire par modèle (7 jours)."""
+ try:
+ rows = query(
+ f"SELECT toStartOfHour(detected_at) AS hour, "
+ f"model_name, count() AS cnt, "
+ f"avg(anomaly_score) AS avg_score, "
+ f"countIf(threat_level IN ('HIGH','CRITICAL')) AS anomalies "
+ f"FROM {_DB}.ml_all_scores "
+ "WHERE detected_at >= now() - INTERVAL 7 DAY "
+ "GROUP BY hour, model_name "
+ "ORDER BY hour"
+ )
+ return {"timeline": rows}
+ except Exception as exc:
+ logger.exception("models timeline query failed")
+ return {"timeline": []}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/models/threats — Threat breakdown per model
+# ---------------------------------------------------------------------------
+@router.get("/models/threats")
+async def models_threats() -> dict[str, Any]:
+ """Répartition des niveaux de menace par modèle."""
+ try:
+ rows = query(
+ f"SELECT model_name, threat_level, count() AS cnt "
+ f"FROM {_DB}.ml_all_scores "
+ "WHERE detected_at >= now() - INTERVAL 7 DAY "
+ "GROUP BY model_name, threat_level "
+ "ORDER BY model_name, cnt DESC"
+ )
+ return {"threats": rows}
+ except Exception as exc:
+ logger.exception("models threats query failed")
+ return {"threats": []}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/classify/stats — Classification summary stats
+# ---------------------------------------------------------------------------
+@router.get("/classify/stats")
+async def classify_stats() -> dict[str, Any]:
+ """Statistiques de classification SOC."""
+ try:
+ _ensure_feedback_table()
+ rows = query(
+ f"SELECT classification, count() AS cnt "
+ f"FROM {_DB}.soc_feedback "
+ "GROUP BY classification"
+ )
+ total = sum(r["cnt"] for r in rows)
+ return {"stats": rows, "total": total}
+ except Exception:
+ return {"stats": [], "total": 0}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/classify/suggested — Top unclassified IPs
+# ---------------------------------------------------------------------------
+@router.get("/classify/suggested")
+async def classify_suggested() -> dict[str, Any]:
+ """IPs détectées non encore classifiées, triées par sévérité."""
+ try:
+ _ensure_feedback_table()
+ rows = query(
+ f"SELECT toString(d.src_ip) AS src_ip, "
+ f"max(d.anomaly_score) AS worst_score, "
+ f"max(d.threat_level) AS threat_level, "
+ f"count() AS detection_count, "
+ f"any(d.ja4) AS ja4, any(d.host) AS host, "
+ f"any(d.asn_org) AS asn_org, any(d.country_code) AS country_code "
+ f"FROM {_DB}.ml_detected_anomalies AS d "
+ f"LEFT JOIN {_DB}.soc_feedback AS f "
+ f"ON d.src_ip = f.src_ip "
+ "WHERE d.detected_at >= now() - INTERVAL 3 DAY "
+ "AND f.src_ip IS NULL "
+ "GROUP BY d.src_ip "
+ "ORDER BY worst_score DESC "
+ "LIMIT 20"
+ )
+ return {"suggested": rows}
+ except Exception as exc:
+ logger.exception("classify suggested query failed")
+ return {"suggested": []}
+
+
# ---------------------------------------------------------------------------
# POST /api/classify — SOC analyst feedback
# ---------------------------------------------------------------------------
diff --git a/services/dashboard/backend/templates/classify.html b/services/dashboard/backend/templates/classify.html
index fdf8748..19576b9 100644
--- a/services/dashboard/backend/templates/classify.html
+++ b/services/dashboard/backend/templates/classify.html
@@ -5,36 +5,96 @@
Classifiez les IPs pour entraîner le modèle XGBoost supervisé. Les labels sont utilisés au prochain cycle ML. Bot : Confirme que l'IP est malveillante. Légitime : Faux positif. Suspect : À surveiller. Workflow : 1. Consultez les IPs suggérées (non classifiées). 2. Classifiez-les. 3. Les labels alimentent XGBoost au prochain cycle. Bot : Confirme une IP malveillante. Légitime : Faux positif. Suspect : À surveiller. Source : soc_feedback → XGBoost trainingFeedback analyste SOC
Saisissez une IP ou cliquez sur une suggestion. La classification est immédiatement enregistrée et sera utilisée par XGBoost au prochain cycle.
+Table : soc_feedback
+IPs détectées comme anomalies dans les 3 derniers jours qui n'ont pas encore de label SOC. Triées par score descendant.
+Action : Cliquez sur une IP pour la pré-remplir dans le formulaire, ou utilisez les boutons rapides.
+Source : ml_detected_anomalies LEFT JOIN soc_feedback
+| IP | Score max | Menace | Détections | JA4 | ASN | Pays | Action | +
|---|
Ratio bot/légitime/suspect dans les labels SOC. Un bon ratio aide XGBoost à apprendre. Visez ≥100 labels par catégorie.
+Source : soc_feedback GROUP BY classification
+Les 50 dernières classifications effectuées par les analystes SOC. Chaque label sera utilisé par XGBoost au prochain cycle ML.
+Source : soc_feedback ORDER BY created_at DESC
+| Date | IP | Classification | Commentaire |
|---|
CRITICAL = score très élevé + multi-signal. HIGH = score au-dessus du seuil. KNOWN_BOT = identifié par dictionnaire. Cliquez sur un segment pour filtrer.
+Source : ml_detected_anomalies
+Motifs de déclenchement : score IF élevé, bot connu, Anubis DENY, etc. Aide à comprendre pourquoi une IP est détectée.
+Source : ml_detected_anomalies.reason
+Autonomous Systems d'où proviennent les menaces. Les hébergeurs (OVH, Hetzner, DigitalOcean) sont souvent en tête car utilisés par les botnets.
+Source : ml_detected_anomalies.asn_org
+Profil moyen des sessions ISP (humaines) vs sessions datacenter (bots potentiels). Les axes sont les features ML normalisées.
+Interprétation : Plus la zone rouge dépasse la verte, plus la feature est discriminante. hit_velocity, fuzzing_index et post_ratio sont typiquement les plus discriminants.
+Source : view_ai_features_1h GROUP BY asn_label
+Variance inter-classe (ISP vs datacenter) de chaque feature. Les features à haute variance discriminent le mieux bots et humains.
+Usage : Les features en tête sont les plus utiles pour le modèle EIF. Celles à variance nulle sont élaguées automatiquement.
+Source : view_ai_features_1h
+Chaque point = une session IP. X = cadence de requêtes, Y = diversité des paths. Les clusters séparés du groupe principal sont des anomalies.
+Action : Cliquez sur un point pour ouvrir la page IP détail.
+Source : view_ai_features_1h
+| Time | Method | Host | Path | HTTP | User-Agent | JA4 | +Time | Method | Status | Host | Path | HTTP | User-Agent | JA4 |
|---|
Ensemble triple-voix : Extended Isolation Forest (EIF) + Autoencoder (AE) + XGBoost supervisé.
-Versions : Chaque cycle crée un nouveau modèle si une dérive est détectée (95% features). Les anciens modèles restent en cache.
-Source : /data/models/*.json, ml_all_scores
+Cycle : Toutes les 30 min, le bot-detector ré-entraîne si une dérive est détectée (≥95% features). Les anciens modèles restent en cache.
+Workflow : Surveillez le volume de scoring, le taux d'anomalie et la santé des modèles. Un taux d'anomalie > 10% peut indiquer une attaque ou un modèle dégradé.
+Source : ml_all_scores (7j), /data/models/*.json
Nombre de sessions scorées par heure et par modèle. La courbe orange montre le score moyen d'anomalie.
+Interprétation : Un creux soudain indique un problème de pipeline. Un pic de score moyen = vague d'attaque.
+Source : ml_all_scores GROUP BY hour, model_name
+Répartition des niveaux de menace (NORMAL, HIGH, CRITICAL, KNOWN_BOT, LEGITIMATE_BROWSER) par modèle.
+Source : ml_all_scores GROUP BY model_name, threat_level
+Pourcentage de sessions classées HIGH/CRITICAL par heure. Les barres montrent le volume, la ligne le taux.
+Seuil d'alerte : Un taux > 10% prolongé mérite investigation.
+Source : ml_all_scores
+Sessions scorées, période active et dernière activité pour chaque modèle. Complet = L3→L7 corrélé, Applicatif = L7 seul.
+Source : ml_all_scores GROUP BY model_name
+| Modèle | Sessions scorées | Premier scoring | Dernier scoring |
|---|
Chaque fichier .json dans /data/models/ décrit un modèle entraîné : version, algorithme, paramètres, métriques de validation.
+Gate de validation : Un modèle n'est utilisé que si val_anomaly_rate < 5% et val_mean_score est raisonnable.
+Source : /data/models/*.json
+Score normalisé [0,1] combinant EIF + AE + XGBoost. La majorité des sessions devraient être proches de 0 (normal). Un pic à droite = vague d'attaque.
+Seuil : Les sessions au-dessus du seuil (typiquement 0.5–0.7) sont classées HIGH/CRITICAL.
+Source : ml_all_scores.anomaly_score
+X = erreur de reconstruction autoencoder (comportement inhabituel). Y = probabilité XGBoost (supervisé sur labels SOC).
+Interprétation : Quadrant haut-droit = consensus des deux modèles. Haut-gauche = XGB dit bot mais AE dit normal → possible label biaisé.
+Source : ml_all_scores.ae_recon_error, xgb_prob
+Toutes les requêtes HTTP capturées (24h). Filtrez par méthode, host ou status pour identifier les patterns suspects.
Workflow : Filtrez POST → cherchez du brute-force → cliquez sur l'IP → investiguez.
+Codes couleur : GET=vert, POST=bleu, PUT=jaune, DELETE=rouge. Status : 2xx=vert, 3xx=jaune, 4xx=orange, 5xx=rouge.
Source : http_logs (24h)
Ratio des méthodes HTTP. Un ratio POST anormalement élevé peut indiquer du brute-force ou du credential stuffing.
+Source : http_logs (24h)
+Les bots utilisent souvent des UAs génériques (python-requests, curl) ou vides. Un UA massivement représenté = potentiel botnet.
+Source : http_logs (24h)
+Les paths comme /wp-admin, /xmlrpc.php, /.env indiquent du scanning. Un path API martelé = possible DDoS L7.
+Source : http_logs (24h)
+| Time | IP | Method | Host | Path | -HTTP Ver | User-Agent | JA4 | Pays | +Time ↕ | +IP | Method | Status | Host | Path | +HTTP | User-Agent | JA4 | Pays |
|---|