feat(dashboard): SOC workflow overhaul — sidebar nav, doc tooltips, full-width layout

- base.html: collapsible sidebar navigation, doc tooltip system, JS helpers
  (fmtNum, fmtPct, fmtDuration, ecGrid, buildTable, docHTML)
- overview.html: SOC command center with stacked timeline, live alerts,
  campaigns panel, browser donut, 6 KPIs
- detections.html: threat color dots, raw score column, click-to-navigate rows
- network.html: JA4 rotation, brute-force, persistent threats tables, 6 KPIs
- ip_detail.html: ASN/country KPIs, AE/XGB/campaign columns, enriched features
- scores/traffic/features/models/classify: page_title blocks + doc tooltips
- api.py: 9 new endpoints (campaigns, brute-force, ja4-rotation, recurrence,
  cascade, alerts, timeline-detail, ua-rotation)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-09 00:29:34 +02:00
parent c994ad4466
commit 2d04288e95
11 changed files with 1137 additions and 592 deletions

View File

@ -803,3 +803,187 @@ async def classifications() -> dict[str, Any]:
except Exception as exc:
logger.exception("classifications query failed")
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/campaigns — HDBSCAN bot campaign clusters
# ---------------------------------------------------------------------------
@router.get("/campaigns")
async def campaigns() -> dict[str, Any]:
"""Campagnes de bots détectées par clustering HDBSCAN."""
try:
rows = query(
f"SELECT campaign_id, "
f"count() AS members, "
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen, "
f"avg(anomaly_score) AS avg_score, "
f"max(anomaly_score) AS max_score, "
f"uniqExact(src_ip) AS unique_ips, "
f"groupUniqArray(10)(ja4) AS ja4_list, "
f"groupUniqArray(5)(asn_org) AS asn_list, "
f"groupUniqArray(5)(country_code) AS countries "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id != '' AND campaign_id != '0' "
"AND detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY campaign_id "
"ORDER BY members DESC LIMIT 50"
)
return {"campaigns": rows}
except Exception as exc:
logger.exception("campaigns query failed")
return {"campaigns": []}
# ---------------------------------------------------------------------------
# GET /api/brute-force — Form brute-force detection
# ---------------------------------------------------------------------------
@router.get("/brute-force")
async def brute_force() -> dict[str, Any]:
"""Détection de brute-force / credential stuffing via view_form_bruteforce_detected."""
try:
rows = query(
f"SELECT toString(src_ip) AS src_ip, host, "
f"post_count, distinct_paths, first_seen, last_seen "
f"FROM {_DB}.view_form_bruteforce_detected "
"ORDER BY post_count DESC LIMIT 100"
)
return {"data": rows}
except Exception as exc:
logger.exception("brute-force query failed")
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/ja4-rotation — JA4 fingerprint rotation detection
# ---------------------------------------------------------------------------
@router.get("/ja4-rotation")
async def ja4_rotation() -> dict[str, Any]:
"""IPs présentant une rotation de fingerprints JA4 (évasion potentielle)."""
try:
rows = query(
f"SELECT toString(src_ip) AS src_ip, host, "
f"distinct_ja4, ja4_list, total_hits, window_start "
f"FROM {_DB}.view_host_ip_ja4_rotation "
"ORDER BY distinct_ja4 DESC LIMIT 100"
)
return {"data": rows}
except Exception as exc:
logger.exception("ja4-rotation query failed")
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/recurrence — Persistent threat IPs
# ---------------------------------------------------------------------------
@router.get("/recurrence")
async def recurrence() -> dict[str, Any]:
"""IPs récurrentes détectées sur plusieurs fenêtres temporelles."""
try:
rows = query(
f"SELECT toString(src_ip) AS src_ip, "
f"recurrence, worst_score, worst_threat, "
f"first_seen, last_seen, "
f"top_ja4, top_host "
f"FROM {_DB}.view_ip_recurrence "
"ORDER BY recurrence DESC, worst_score DESC LIMIT 100"
)
return {"data": rows}
except Exception as exc:
logger.exception("recurrence query failed")
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/cascade/{ip} — Resource cascade for headless detection
# ---------------------------------------------------------------------------
@router.get("/cascade/{ip}")
async def cascade(ip: str) -> dict[str, Any]:
"""Cascade de ressources (détection navigateurs headless) pour une IP."""
clean_ip = ip.replace("::ffff:", "")
try:
rows = query(
f"SELECT toString(src_ip) AS src_ip, host, "
f"page_count, avg_sub_delay_ms, stddev_sub_delay_ms, "
f"max_sub_resources, window_start "
f"FROM {_DB}.view_resource_cascade_1h "
"WHERE src_ip = toIPv6({ip:String}) "
"ORDER BY window_start DESC LIMIT 50",
{"ip": clean_ip},
)
return {"data": rows}
except Exception as exc:
logger.exception("cascade query failed for %s", ip)
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/alerts — Live alert feed (recent HIGH/CRITICAL)
# ---------------------------------------------------------------------------
@router.get("/alerts")
async def alerts(
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""Flux d'alertes en temps réel (CRITICAL, HIGH, KNOWN_BOT)."""
try:
rows = query(
f"SELECT detected_at, toString(src_ip) AS src_ip, "
f"anomaly_score, threat_level, ja4, host, "
f"asn_org, country_code, bot_name, campaign_id, "
f"hits, hit_velocity, reason "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"ORDER BY detected_at DESC "
f"LIMIT {{lim:UInt32}}",
{"lim": limit},
)
return {"alerts": rows}
except Exception as exc:
logger.exception("alerts query failed")
return {"alerts": []}
# ---------------------------------------------------------------------------
# GET /api/timeline-detail — Hourly threat-level breakdown
# ---------------------------------------------------------------------------
@router.get("/timeline-detail")
async def timeline_detail() -> dict[str, Any]:
"""Timeline horaire avec ventilation par threat level."""
try:
rows = query(
f"SELECT toStartOfHour(detected_at) AS hour, "
f"threat_level, count() AS cnt "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE detected_at >= now() - INTERVAL 1 DAY "
"GROUP BY hour, threat_level "
"ORDER BY hour"
)
# Pivot: group by hour
hours: dict[str, dict] = {}
for r in rows:
h = str(r["hour"])
if h not in hours:
hours[h] = {"hour": h}
hours[h][r["threat_level"]] = r["cnt"]
return {"timeline": list(hours.values())}
except Exception as exc:
logger.exception("timeline-detail query failed")
return {"timeline": []}
# ---------------------------------------------------------------------------
# GET /api/ua-rotation — User-Agent rotation detection
# ---------------------------------------------------------------------------
@router.get("/ua-rotation")
async def ua_rotation() -> dict[str, Any]:
"""IPs avec rotation de User-Agent (évasion potentielle)."""
try:
rows = query(
f"SELECT toString(src_ip) AS src_ip, ja4, "
f"distinct_ua_count, ua_samples, total_requests, window_start "
f"FROM {_DB}.view_dashboard_user_agents "
"ORDER BY distinct_ua_count DESC LIMIT 100"
)
return {"data": rows}
except Exception as exc:
logger.exception("ua-rotation query failed")
return {"data": []}