diff --git a/backend/main.py b/backend/main.py index 063a53a..ee3a3f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ import os from .config import settings from .database import db from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints -from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features +from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary # Configuration logging logging.basicConfig( @@ -82,6 +82,7 @@ app.include_router(heatmap.router) app.include_router(botnets.router) app.include_router(rotation.router) app.include_router(ml_features.router) +app.include_router(investigation_summary.router) # Route pour servir le frontend diff --git a/backend/models.py b/backend/models.py index a0000f6..f3397ad 100644 --- a/backend/models.py +++ b/backend/models.py @@ -75,6 +75,8 @@ class Detection(BaseModel): post_ratio: float reason: str client_headers: str = "" + asn_score: Optional[float] = None + asn_rep_label: str = "" class DetectionsListResponse(BaseModel): diff --git a/backend/routes/detections.py b/backend/routes/detections.py index 10183a1..71aafcb 100644 --- a/backend/routes/detections.py +++ b/backend/routes/detections.py @@ -97,8 +97,10 @@ async def get_detections( hit_velocity, fuzzing_index, post_ratio, - reason + reason, + ar.label AS asn_rep_label FROM ml_detected_anomalies + LEFT JOIN mabase_prod.asn_reputation ar ON ar.src_asn = toUInt32OrZero(asn_number) WHERE {where_clause} ORDER BY {sort_by} {sort_order} LIMIT %(limit)s OFFSET %(offset)s @@ -109,6 +111,21 @@ async def get_detections( result = db.query(main_query, params) + def _label_to_score(label: str) -> float | None: + if not label: + return None + mapping = { + 'human': 0.9, + 'bot': 0.05, + 'proxy': 0.25, + 'vpn': 0.3, + 'tor': 0.1, + 'datacenter': 0.4, + 'scanner': 0.05, + 'malicious': 0.05, + } + return mapping.get(label.lower(), 0.5) + detections = [ Detection( detected_at=row[0], @@ -130,7 +147,9 @@ async def get_detections( hit_velocity=float(row[16]) if row[16] else 0.0, fuzzing_index=float(row[17]) if row[17] else 0.0, post_ratio=float(row[18]) if row[18] else 0.0, - reason=row[19] or "" + reason=row[19] or "", + asn_rep_label=row[20] or "", + asn_score=_label_to_score(row[20] or ""), ) for row in result.result_rows ] diff --git a/backend/routes/investigation_summary.py b/backend/routes/investigation_summary.py new file mode 100644 index 0000000..46bad70 --- /dev/null +++ b/backend/routes/investigation_summary.py @@ -0,0 +1,165 @@ +""" +Endpoint d'investigation enrichie pour une IP donnée. +Agrège en une seule requête les données provenant de toutes les sources : +ml_detected_anomalies, view_form_bruteforce_detected, view_tcp_spoofing_detected, +agg_host_ip_ja4_1h (rotation JA4), view_ip_recurrence, view_ai_features_1h. +""" +from fastapi import APIRouter, HTTPException + +from ..database import db + +router = APIRouter(prefix="/api/investigation", tags=["investigation"]) + + +@router.get("/{ip}/summary") +async def get_ip_full_summary(ip: str): + """ + Synthèse complète pour une IP : toutes les sources en un appel. + Normalise l'IP (accepte ::ffff:x.x.x.x ou x.x.x.x). + """ + clean_ip = ip.replace("::ffff:", "").strip() + try: + # ── 1. Score ML / features ───────────────────────────────────────────── + ml_sql = """ + SELECT + max(abs(anomaly_score)) AS max_score, + any(threat_level) AS threat_level, + any(bot_name) AS bot_name, + count() AS total_detections, + uniq(host) AS distinct_hosts, + uniq(ja4) AS distinct_ja4 + FROM mabase_prod.ml_detected_anomalies + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + """ + ml_res = db.query(ml_sql, {"ip": clean_ip}) + ml_row = ml_res.result_rows[0] if ml_res.result_rows else None + ml_data = { + "max_score": round(float(ml_row[0] or 0), 2) if ml_row else 0, + "threat_level": str(ml_row[1] or "") if ml_row else "", + "attack_type": str(ml_row[2] or "") if ml_row else "", + "total_detections": int(ml_row[3] or 0) if ml_row else 0, + "distinct_hosts": int(ml_row[4] or 0) if ml_row else 0, + "distinct_ja4": int(ml_row[5] or 0) if ml_row else 0, + } + + # ── 2. Brute force ───────────────────────────────────────────────────── + bf_sql = """ + SELECT + uniq(host) AS hosts_attacked, + sum(hits) AS total_hits, + sum(query_params_count) AS total_params, + groupArray(3)(host) AS top_hosts + FROM mabase_prod.view_form_bruteforce_detected + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + """ + bf_res = db.query(bf_sql, {"ip": clean_ip}) + bf_row = bf_res.result_rows[0] if bf_res.result_rows else None + bf_data = { + "active": bool(bf_row and int(bf_row[1] or 0) > 0), + "hosts_attacked": int(bf_row[0] or 0) if bf_row else 0, + "total_hits": int(bf_row[1] or 0) if bf_row else 0, + "total_params": int(bf_row[2] or 0) if bf_row else 0, + "top_hosts": [str(h) for h in (bf_row[3] or [])] if bf_row else [], + } + + # ── 3. TCP spoofing ──────────────────────────────────────────────────── + tcp_sql = """ + SELECT tcp_ttl, first_ua + FROM mabase_prod.view_tcp_spoofing_detected + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + AND tcp_ttl > 0 + LIMIT 1 + """ + tcp_res = db.query(tcp_sql, {"ip": clean_ip}) + tcp_data = {"detected": False, "tcp_ttl": None, "suspected_os": None} + if tcp_res.result_rows: + ttl = int(tcp_res.result_rows[0][0]) + if 52 <= ttl <= 65: + sus_os = "Linux/Mac" + elif 110 <= ttl <= 135: + sus_os = "Windows" + else: + sus_os = "Unknown" + ua = str(tcp_res.result_rows[0][1] or "") + dec_os = "Windows" if "Windows" in ua else ("macOS" if "Mac OS X" in ua else "Linux/Android" if "Linux" in ua else "Unknown") + spoof = sus_os != "Unknown" and dec_os != "Unknown" and sus_os != dec_os + tcp_data = { + "detected": spoof, + "tcp_ttl": ttl, + "suspected_os": sus_os, + "declared_os": dec_os, + } + + # ── 4. JA4 rotation ──────────────────────────────────────────────────── + rot_sql = """ + SELECT distinct_ja4_count, total_hits + FROM mabase_prod.view_host_ip_ja4_rotation + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + LIMIT 1 + """ + rot_res = db.query(rot_sql, {"ip": clean_ip}) + rot_data = {"rotating": False, "distinct_ja4_count": 0} + if rot_res.result_rows: + row = rot_res.result_rows[0] + cnt = int(row[0] or 0) + rot_data = {"rotating": cnt > 1, "distinct_ja4_count": cnt, "total_hits": int(row[1] or 0)} + + # ── 5. Persistance ───────────────────────────────────────────────────── + pers_sql = """ + SELECT recurrence, worst_score, worst_threat_level, first_seen, last_seen + FROM mabase_prod.view_ip_recurrence + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + LIMIT 1 + """ + pers_res = db.query(pers_sql, {"ip": clean_ip}) + pers_data = {"persistent": False, "recurrence": 0} + if pers_res.result_rows: + row = pers_res.result_rows[0] + pers_data = { + "persistent": True, + "recurrence": int(row[0] or 0), + "worst_score": round(float(row[1] or 0), 2), + "worst_threat_level":str(row[2] or ""), + "first_seen": str(row[3]), + "last_seen": str(row[4]), + } + + # ── 6. Timeline 24h ──────────────────────────────────────────────────── + tl_sql = """ + SELECT + toHour(window_start) AS hour, + sum(hits) AS hits, + groupUniqArray(3)(ja4) AS ja4s + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + AND window_start >= now() - INTERVAL 24 HOUR + GROUP BY hour + ORDER BY hour ASC + """ + tl_res = db.query(tl_sql, {"ip": clean_ip}) + timeline = [ + {"hour": int(r[0]), "hits": int(r[1]), "ja4s": [str(j) for j in (r[2] or [])]} + for r in tl_res.result_rows + ] + + # ── Global risk score (heuristic) ────────────────────────────────────── + risk = 0 + risk += min(50, ml_data["max_score"] * 50) + if bf_data["active"]: risk += 20 + if tcp_data["detected"]: risk += 15 + if rot_data["rotating"]: risk += min(15, rot_data["distinct_ja4_count"] * 3) + if pers_data["persistent"]: risk += min(10, pers_data["recurrence"] * 2) + risk = min(100, round(risk)) + + return { + "ip": clean_ip, + "risk_score": risk, + "ml": ml_data, + "bruteforce": bf_data, + "tcp_spoofing":tcp_data, + "ja4_rotation":rot_data, + "persistence": pers_data, + "timeline_24h":timeline, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/metrics.py b/backend/routes/metrics.py index 0646fc4..660519e 100644 --- a/backend/routes/metrics.py +++ b/backend/routes/metrics.py @@ -120,3 +120,56 @@ async def get_threat_distribution(): except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") + + +@router.get("/baseline") +async def get_metrics_baseline(): + """ + Compare les métriques actuelles (24h) vs hier (24h-48h) pour afficher les tendances. + """ + try: + query = """ + SELECT + countIf(detected_at >= now() - INTERVAL 24 HOUR) AS today_total, + countIf(detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_total, + uniqIf(src_ip, detected_at >= now() - INTERVAL 24 HOUR) AS today_ips, + uniqIf(src_ip, detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_ips, + countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 24 HOUR) AS today_critical, + countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_critical + FROM ml_detected_anomalies + WHERE detected_at >= now() - INTERVAL 48 HOUR + """ + r = db.query(query) + row = r.result_rows[0] if r.result_rows else None + + def pct_change(today: int, yesterday: int) -> float: + if yesterday == 0: + return 100.0 if today > 0 else 0.0 + return round((today - yesterday) / yesterday * 100, 1) + + today_total = int(row[0] or 0) if row else 0 + yesterday_total = int(row[1] or 0) if row else 0 + today_ips = int(row[2] or 0) if row else 0 + yesterday_ips = int(row[3] or 0) if row else 0 + today_crit = int(row[4] or 0) if row else 0 + yesterday_crit = int(row[5] or 0) if row else 0 + + return { + "total_detections": { + "today": today_total, + "yesterday": yesterday_total, + "pct_change": pct_change(today_total, yesterday_total), + }, + "unique_ips": { + "today": today_ips, + "yesterday": yesterday_ips, + "pct_change": pct_change(today_ips, yesterday_ips), + }, + "critical_alerts": { + "today": today_crit, + "yesterday": yesterday_crit, + "pct_change": pct_change(today_crit, yesterday_crit), + }, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur baseline: {str(e)}") diff --git a/backend/routes/rotation.py b/backend/routes/rotation.py index a792f0a..bcbff3b 100644 --- a/backend/routes/rotation.py +++ b/backend/routes/rotation.py @@ -1,6 +1,7 @@ """ Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes """ +import math from fastapi import APIRouter, HTTPException, Query from ..database import db @@ -99,3 +100,114 @@ async def get_ip_ja4_history(ip: str): return {"ip": ip, "ja4_history": items, "total": len(items)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/sophistication") +async def get_sophistication(limit: int = Query(50, ge=1, le=500)): + """Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce).""" + try: + # Separate queries merged in Python to avoid view JOIN issues + rot_result = db.query(""" + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + distinct_ja4_count + FROM mabase_prod.view_host_ip_ja4_rotation + """) + rotation_map = {str(row[0]): int(row[1]) for row in rot_result.result_rows} + + rec_result = db.query(""" + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + recurrence + FROM mabase_prod.view_ip_recurrence + """) + recurrence_map = {str(row[0]): int(row[1]) for row in rec_result.result_rows} + + bf_result = db.query(""" + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + sum(hits) AS total_hits + FROM mabase_prod.view_form_bruteforce_detected + GROUP BY ip + """) + bruteforce_map = {str(row[0]): int(row[1]) for row in bf_result.result_rows} + + # Start from IPs that appear in rotation view (most evasive) + items = [] + for ip, ja4_count in rotation_map.items(): + recurrence = recurrence_map.get(ip, 0) + bf_hits = bruteforce_map.get(ip, 0) + score = min(100.0, ja4_count * 10 + recurrence * 20 + min(30.0, math.log(bf_hits + 1) * 5)) + if score > 80: + tier = "APT-like" + elif score > 50: + tier = "Advanced" + elif score > 20: + tier = "Automated" + else: + tier = "Basic" + items.append({ + "ip": ip, + "ja4_rotation_count": ja4_count, + "recurrence": recurrence, + "bruteforce_hits": bf_hits, + "sophistication_score": round(score, 1), + "tier": tier, + }) + + items.sort(key=lambda x: x["sophistication_score"], reverse=True) + items = items[:limit] + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/proactive-hunt") +async def get_proactive_hunt( + min_recurrence: int = Query(2, ge=1, description="Récurrence minimale"), + min_days: int = Query(2, ge=0, description="Jours d'activité minimum"), + limit: int = Query(50, ge=1, le=500), +): + """IPs volant sous le radar : récurrentes mais sous le seuil de détection normal.""" + try: + sql = """ + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + recurrence, + worst_score, + worst_threat_level, + first_seen, + last_seen, + dateDiff('day', first_seen, last_seen) AS days_active + FROM mabase_prod.view_ip_recurrence + WHERE recurrence >= %(min_recurrence)s + AND abs(worst_score) < 0.5 + AND dateDiff('day', first_seen, last_seen) >= %(min_days)s + ORDER BY recurrence DESC, worst_score ASC + LIMIT %(limit)s + """ + result = db.query(sql, { + "min_recurrence": min_recurrence, + "min_days": min_days, + "limit": limit, + }) + items = [] + for row in result.result_rows: + recurrence = int(row[1]) + worst_score = float(row[2] or 0) + days_active = int(row[6] or 0) + ratio = recurrence / (worst_score + 0.1) + risk = "Évadeur potentiel" if ratio > 10 else "Persistant modéré" + items.append({ + "ip": str(row[0]), + "recurrence": recurrence, + "worst_score": round(worst_score, 4), + "worst_threat_level": str(row[3] or ""), + "first_seen": str(row[4]), + "last_seen": str(row[5]), + "days_active": days_active, + "risk_assessment": risk, + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19b0499..e6025ad 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,6 +58,8 @@ export interface Detection { post_ratio: number; reason: string; client_headers: string; + asn_score?: number | null; + asn_rep_label?: string; } export interface DetectionsListResponse { diff --git a/frontend/src/components/DetectionsList.tsx b/frontend/src/components/DetectionsList.tsx index e533ddc..e583801 100644 --- a/frontend/src/components/DetectionsList.tsx +++ b/frontend/src/components/DetectionsList.tsx @@ -451,6 +451,7 @@ export function DetectionsList() { {detection.asn_number && (
| IP | +Rotation JA4 | +Récurrence | +Hits bruteforce | +Score sophistication | +Tier | ++ |
|---|---|---|---|---|---|---|
| {item.ip} | ++ + {item.ja4_rotation_count} JA4 + + | +{item.recurrence} | +{formatNumber(item.bruteforce_hits)} | +
+
+
+
+
+
+
+ {item.sophistication_score}
+
+ |
+ + + {item.tier} + + | ++ + | +
| IP | +Récurrence | +Score max | +Jours actifs | +Timeline | +Évaluation | ++ |
|---|---|---|---|---|---|---|
| {item.ip} | ++ + {item.recurrence}× + + | ++ {item.worst_score.toFixed(3)} + | +{item.days_active}j | +
+
+
+ Premier: {formatDate(item.first_seen)}
+ Dernier: {formatDate(item.last_seen)}
+ |
+ + + {item.risk_assessment} + + | ++ + | +