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

Feedback analyste SOC

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 training

{% endblock %} {% block content %} -
-
-
- - -
-
- - -
-
- - -
- -
+
+ +
+
Total classifiées
0
+
🤖 Bots confirmés
0
+
✅ Légitimes
0
+
⚠️ Suspects
0
+ +
+ +
+
Nouvelle classification +
+

Classifier une IP

+

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 suggérées (non classifiées) +
+

Suggestions de classification

+

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

+
+
+
+ + +
IPScore maxMenaceDétectionsJA4ASNPaysAction
+
+
+ + +
+
Répartition des classifications +
+

Distribution des labels

+

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

+
+
+
+
+
+
+ -
-

Classifications récentes

-
+
+
Historique des classifications +
+

Classifications récentes

+

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

+
+
+
DateIPClassificationCommentaire
@@ -44,30 +104,111 @@ {% endblock %} {% block scripts %} {% endblock %} diff --git a/services/dashboard/backend/templates/detections.html b/services/dashboard/backend/templates/detections.html index c56c37f..2ec08b5 100644 --- a/services/dashboard/backend/templates/detections.html +++ b/services/dashboard/backend/templates/detections.html @@ -14,15 +14,33 @@
-
Par threat level
+
Par threat level +
+

Répartition des menaces

+

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

+
+
-
Top raisons
+
Top raisons +
+

Raisons de détection

+

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

+
+
-
Top ASN détectés
+
Top ASN détectés +
+

ASN des détections

+

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

+
+
diff --git a/services/dashboard/backend/templates/features.html b/services/dashboard/backend/templates/features.html index 63a8182..4612d1e 100644 --- a/services/dashboard/backend/templates/features.html +++ b/services/dashboard/backend/templates/features.html @@ -14,20 +14,41 @@
-
-

Profil Humain vs Bot (Radar)

-
+
+
Profil Humain vs Bot (Radar) +
+

Comparaison ISP vs Datacenter

+

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

+
+
+
-
-

Importance des features (Variance)

-
+
+
Importance des features (Variance) +
+

Feature importance

+

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

+
+
+
-
-

Scatter — Hit Velocity vs Fuzzing Index

-
+
+
Scatter — Hit Velocity vs Fuzzing Index +
+

Scatter bidimensionnel

+

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

+
+
+
diff --git a/services/dashboard/backend/templates/ip_detail.html b/services/dashboard/backend/templates/ip_detail.html index cf46612..5048a5c 100644 --- a/services/dashboard/backend/templates/ip_detail.html +++ b/services/dashboard/backend/templates/ip_detail.html @@ -91,10 +91,27 @@
- +
TimeMethodHostPathHTTPUser-AgentJA4TimeMethodStatusHostPathHTTPUser-AgentJA4
+ + +
{% endblock %} {% block scripts %} @@ -111,9 +128,10 @@ function initChart(id) { async function loadIP() { try { - const [d, radar] = await Promise.all([ + const [d, radar, cascade] = await Promise.all([ fetch(`/api/ip/${encodeURIComponent(IP)}`).then(r=>r.json()), fetch(`/api/ip/${encodeURIComponent(IP)}/radar`).then(r=>r.json()), + fetch(`/api/cascade/${encodeURIComponent(IP)}`).then(r=>r.json()), ]); // KPIs @@ -209,15 +227,31 @@ async function loadIP() { } // HTTP logs + const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400'; document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => ` ${row.time||''} ${escapeHtml(row.method||'')} + ${row.status||''} ${escapeHtml(row.host||'')} ${escapeHtml(row.path||'')} ${escapeHtml(row.http_version||'')} ${escapeHtml(row.header_user_agent||'')} ${escapeHtml(row.ja4||'')} - `).join('') || 'Aucun log'; + `).join('') || 'Aucun log'; + + // Cascade + const cascadeRows = cascade.data || []; + if (cascadeRows.length) { + document.getElementById('cascade-section').style.display = ''; + document.getElementById('cascade-body').innerHTML = cascadeRows.map(row => ` + ${(row.window_start||'').substring(0,16)} + ${escapeHtml(row.host||'')} + ${row.page_count||0} + ${row.max_sub_resources||0} + ${(row.avg_sub_delay_ms||0).toFixed(0)} + ${(row.stddev_sub_delay_ms||0).toFixed(0)} + `).join(''); + } } catch(e) { console.error(e); } } diff --git a/services/dashboard/backend/templates/models.html b/services/dashboard/backend/templates/models.html index d643cff..fea17d8 100644 --- a/services/dashboard/backend/templates/models.html +++ b/services/dashboard/backend/templates/models.html @@ -3,27 +3,90 @@ {% block page_title %} Modèles ML
-

État des modèles ML

+

Monitoring des modèles ML

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

{% endblock %} {% block content %} -
- -
-

Statistiques de scoring (7 derniers jours)

+
+ +
+
Sessions scorées (7j)
+
Modèles actifs
+
Anomalies détectées
+
Taux d'anomalie
+
Dernier scoring
+
Dernier entraînement
+
+ + +
+
+
Volume de scoring +
+

Timeline de scoring

+

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 menaces +
+

Menaces par modèle

+

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

+
+
+
+
+
+ + +
+
Taux d'anomalie horaire +
+

Anomaly rate over time

+

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

+
+
+
+
+ + +
+
Statistiques de scoring (7 jours) +
+

Résumé par modèle

+

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èleSessions scoréesPremier scoringDernier scoring
- -
-

Métadonnées des modèles

-
+ + +
+
Versions des modèles +
+

Métadonnées des modèles

+

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

+
+
+
Chargement...
@@ -31,43 +94,173 @@ {% endblock %} {% block scripts %} {% endblock %} diff --git a/services/dashboard/backend/templates/scores.html b/services/dashboard/backend/templates/scores.html index 4098a3c..cf3608f 100644 --- a/services/dashboard/backend/templates/scores.html +++ b/services/dashboard/backend/templates/scores.html @@ -14,13 +14,27 @@
-
-

Distribution des scores d'anomalie

-
+
+
Distribution des scores +
+

Histogramme des scores d'anomalie

+

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

+
+
+
-
-

AE Error vs XGB Probability

-
+
+
AE Error vs XGB Probability +
+

Scatter bi-modèle

+

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

+
+
+
@@ -30,7 +44,9 @@ +
+
@@ -76,11 +92,13 @@ async function loadScores() { if(sASN) params.set('asn_org',sASN); if(sCountry) params.set('country_code',sCountry); if(sJA4) params.set('ja4',sJA4); + const sSearch = document.getElementById('search-input').value.trim(); + if(sSearch) params.set('search',sSearch); try { const r = await fetch('/api/scores?'+params); const d = await r.json(); const tbody = document.getElementById('scores-body'); - tbody.innerHTML = (d.data||[]).map(row => ` + tbody.innerHTML = (d.data||[]).map(row => ` ${row.detected_at||''} ${fmtIP(row.src_ip)} ${fmtScore(row.anomaly_score)} @@ -88,9 +106,9 @@ async function loadScores() { ${(row.ae_recon_error||0).toFixed(6)} ${(row.xgb_prob||0).toFixed(4)} ${fmtThreatLink(row.threat_level)} - ${row.model_name||''} + ${escapeHtml(row.model_name||'')} ${fmtJA4(row.ja4)} - ${row.host||''} + ${escapeHtml(row.host||'')} ${row.hits||0} ${fmtCountry(row.country_code)} `).join('') || 'Aucun score'; @@ -112,6 +130,12 @@ document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => { btn.classList.add('active'); sThreat = btn.dataset.filter; sPage=1; loadScores(); }); +// Search with debounce +let sSearchTimer; +document.getElementById('search-input').addEventListener('input', () => { + clearTimeout(sSearchTimer); + sSearchTimer = setTimeout(() => { sPage=1; loadScores(); }, 300); +}); loadScores(); // Score distribution charts diff --git a/services/dashboard/backend/templates/traffic.html b/services/dashboard/backend/templates/traffic.html index 727e2a0..29c1269 100644 --- a/services/dashboard/backend/templates/traffic.html +++ b/services/dashboard/backend/templates/traffic.html @@ -6,6 +6,7 @@

Logs HTTP bruts

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)

{% endblock %} @@ -13,17 +14,35 @@
-
-

Méthodes HTTP

-
+
+
Méthodes HTTP +
+

Distribution des méthodes

+

Ratio des méthodes HTTP. Un ratio POST anormalement élevé peut indiquer du brute-force ou du credential stuffing.

+

Source : http_logs (24h)

+
+
+
-
-

Top 5 User-Agents

-
+
+
Top User-Agents +
+

User-Agents les plus fréquents

+

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)

+
+
+
-
-

Top 5 Paths

-
+
+
Top Paths +
+

Chemins les plus accédés

+

Les paths comme /wp-admin, /xmlrpc.php, /.env indiquent du scanning. Un path API martelé = possible DDoS L7.

+

Source : http_logs (24h)

+
+
+
@@ -33,12 +52,14 @@ +
-
+
- - + + +
TimeIPMethodHostPathHTTP VerUser-AgentJA4PaysTime ↕IPMethodStatusHostPathHTTPUser-AgentJA4Pays
@@ -53,40 +74,56 @@ {% endblock %} {% block scripts %}