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:
@ -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": []}
|
||||
|
||||
@ -16,28 +16,30 @@
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
|
||||
surface: { 800:'#1e293b', 900:'#0f172a', 950:'#020617' },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'] },
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
/* ── Threat badges ── */
|
||||
.threat-critical { color: #ef4444; font-weight: 700; }
|
||||
.threat-high { color: #f97316; font-weight: 600; }
|
||||
.threat-medium { color: #eab308; }
|
||||
.threat-low { color: #22c55e; }
|
||||
.threat-normal { color: #6b7280; }
|
||||
.kpi-card { @apply bg-gray-800 rounded-xl p-5 border border-gray-700; }
|
||||
/* ── KPI cards ── */
|
||||
.kpi-card { @apply bg-gray-900/80 rounded-xl p-4 border border-gray-800 backdrop-blur; }
|
||||
/* ── Data table ── */
|
||||
.data-table { @apply w-full text-sm text-left; }
|
||||
.data-table th { @apply px-4 py-3 bg-gray-800 text-gray-300 font-medium border-b border-gray-700 sticky top-0; }
|
||||
.data-table td { @apply px-4 py-2.5 border-b border-gray-800 text-gray-300; }
|
||||
.data-table tbody tr:hover { @apply bg-gray-800/50; }
|
||||
.nav-link { @apply px-4 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors text-sm font-medium; }
|
||||
.nav-link.active { @apply bg-brand-600 text-white; }
|
||||
.data-table th { @apply px-3 py-2.5 bg-gray-900 text-gray-400 font-medium border-b border-gray-800 sticky top-0 text-xs uppercase tracking-wider; }
|
||||
.data-table td { @apply px-3 py-2 border-b border-gray-800/50 text-gray-300; }
|
||||
.data-table tbody tr:hover { @apply bg-gray-800/40; }
|
||||
.data-table tbody tr { @apply cursor-pointer transition-colors; }
|
||||
/* ── Badges ── */
|
||||
.badge { @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; }
|
||||
.badge-critical { @apply bg-red-500/20 text-red-400; }
|
||||
.badge-high { @apply bg-orange-500/20 text-orange-400; }
|
||||
@ -45,114 +47,203 @@
|
||||
.badge-low { @apply bg-green-500/20 text-green-400; }
|
||||
.badge-normal { @apply bg-gray-500/20 text-gray-400; }
|
||||
.badge-known { @apply bg-blue-500/20 text-blue-400; }
|
||||
/* ── Filter buttons ── */
|
||||
.filter-btn { @apply px-3 py-1.5 text-xs rounded-lg border border-gray-700 text-gray-400 hover:border-brand-500 hover:text-brand-500 transition-colors cursor-pointer; }
|
||||
.filter-btn.active { @apply border-brand-500 bg-brand-500/20 text-brand-500; }
|
||||
|
||||
/* Micro-animations */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-in { animation: fadeUp 0.4s ease-out both; }
|
||||
.kpi-card { animation: fadeUp 0.4s ease-out both; }
|
||||
/* ── Section card ── */
|
||||
.section-card { @apply bg-gray-900/80 rounded-xl border border-gray-800 backdrop-blur; }
|
||||
.section-header { @apply flex items-center justify-between px-5 py-3 border-b border-gray-800; }
|
||||
.section-title { @apply text-sm font-semibold text-gray-200 flex items-center gap-2; }
|
||||
.section-body { @apply p-5; }
|
||||
/* ── Sidebar ── */
|
||||
.sidebar { width: 220px; transition: width 0.2s ease; }
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
.sidebar.collapsed .nav-text { display: none; }
|
||||
.sidebar.collapsed .nav-group-title { display: none; }
|
||||
.sidebar.collapsed .sidebar-logo-text { display: none; }
|
||||
.nav-item { @apply flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800/60 transition-colors text-sm cursor-pointer; }
|
||||
.nav-item.active { @apply bg-brand-600/20 text-brand-400 border-l-2 border-brand-500; }
|
||||
.nav-group-title { @apply text-[10px] uppercase tracking-widest text-gray-600 px-3 pt-4 pb-1; }
|
||||
/* ── Doc tooltips ── */
|
||||
.doc-btn { @apply inline-flex items-center justify-center w-5 h-5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors cursor-help text-xs; }
|
||||
.doc-panel { @apply hidden absolute z-50 w-80 p-4 bg-gray-800 border border-gray-700 rounded-xl shadow-2xl text-xs text-gray-300 leading-relaxed; }
|
||||
.doc-panel.show { @apply block; }
|
||||
.doc-panel h4 { @apply text-white font-semibold text-sm mb-2; }
|
||||
.doc-panel p { @apply mb-2; }
|
||||
.doc-panel .doc-source { @apply text-gray-500 italic mt-2 pt-2 border-t border-gray-700; }
|
||||
/* ── Animations ── */
|
||||
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
.animate-in { animation: fadeUp 0.3s ease-out both; }
|
||||
.kpi-card { animation: fadeUp 0.3s ease-out both; }
|
||||
.kpi-card:nth-child(1) { animation-delay: 0ms; }
|
||||
.kpi-card:nth-child(2) { animation-delay: 50ms; }
|
||||
.kpi-card:nth-child(3) { animation-delay: 100ms; }
|
||||
.kpi-card:nth-child(4) { animation-delay: 150ms; }
|
||||
.kpi-card:nth-child(5) { animation-delay: 200ms; }
|
||||
.kpi-card:nth-child(6) { animation-delay: 250ms; }
|
||||
|
||||
/* Live status pulse */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px; height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
.kpi-card:nth-child(2) { animation-delay: 40ms; }
|
||||
.kpi-card:nth-child(3) { animation-delay: 80ms; }
|
||||
.kpi-card:nth-child(4) { animation-delay: 120ms; }
|
||||
.kpi-card:nth-child(5) { animation-delay: 160ms; }
|
||||
.kpi-card:nth-child(6) { animation-delay: 200ms; }
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1;} 50%{opacity:0.4;} }
|
||||
.live-dot { width:6px; height:6px; background:#22c55e; border-radius:50%; display:inline-block; animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-200 min-h-screen">
|
||||
<!-- Top Nav -->
|
||||
<nav class="sticky top-0 z-50 border-b border-gray-800" style="background: linear-gradient(135deg, #0f172a 0%, #111827 50%, #0f172a 100%);">
|
||||
<div class="max-w-[1600px] mx-auto px-4 flex items-center h-14 gap-2">
|
||||
<a href="/" class="flex items-center gap-2 mr-6">
|
||||
<div class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">J4</div>
|
||||
<span class="text-white font-semibold hidden sm:inline">JA4 SOC</span>
|
||||
<body class="bg-gray-950 text-gray-200 min-h-screen flex">
|
||||
|
||||
<!-- ═══ Sidebar Navigation ═══ -->
|
||||
<aside id="sidebar" class="sidebar fixed top-0 left-0 h-screen bg-gray-950 border-r border-gray-800 flex flex-col z-50 overflow-hidden">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-2 px-3 h-14 border-b border-gray-800 shrink-0">
|
||||
<button onclick="toggleSidebar()" class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm hover:bg-brand-500 transition-colors shrink-0">J4</button>
|
||||
<span class="sidebar-logo-text text-white font-semibold text-sm whitespace-nowrap">JA4 SOC</span>
|
||||
</div>
|
||||
<!-- Nav groups -->
|
||||
<nav class="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||
<div class="nav-group-title">Surveillance</div>
|
||||
<a href="/" class="nav-item {% if active_page == 'overview' %}active{% endif %}" title="Vue d'ensemble">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||
<span class="nav-text">Overview</span>
|
||||
</a>
|
||||
<a href="/" class="nav-link {% if active_page == 'overview' %}active{% endif %}">Overview</a>
|
||||
<a href="/detections" class="nav-link {% if active_page == 'detections' %}active{% endif %}">Détections</a>
|
||||
<a href="/scores" class="nav-link {% if active_page == 'scores' %}active{% endif %}">Scores</a>
|
||||
<a href="/traffic" class="nav-link {% if active_page == 'traffic' %}active{% endif %}">Trafic</a>
|
||||
<a href="/features" class="nav-link {% if active_page == 'features' %}active{% endif %}">Features</a>
|
||||
<a href="/network" class="nav-link {% if active_page == 'network' %}active{% endif %}">Réseau</a>
|
||||
<a href="/models" class="nav-link {% if active_page == 'models' %}active{% endif %}">Modèles</a>
|
||||
<a href="/classify" class="nav-link {% if active_page == 'classify' %}active{% endif %}">Classifier</a>
|
||||
<div class="flex-1"></div>
|
||||
<span class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<a href="/detections" class="nav-item {% if active_page == 'detections' %}active{% endif %}" title="Détections d'anomalies">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
<span class="nav-text">Détections</span>
|
||||
</a>
|
||||
<a href="/scores" class="nav-item {% if active_page == 'scores' %}active{% endif %}" title="Scores ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
<span class="nav-text">Scores</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group-title">Investigation</div>
|
||||
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z"/><path stroke-linecap="round" stroke-width="2" d="M9 12h6M9 8h6M9 16h3"/></svg>
|
||||
<span class="nav-text">Trafic</span>
|
||||
</a>
|
||||
<a href="/network" class="nav-item {% if active_page == 'network' %}active{% endif %}" title="Analyse réseau">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
<span class="nav-text">Réseau</span>
|
||||
</a>
|
||||
<a href="/features" class="nav-item {% if active_page == 'features' %}active{% endif %}" title="Features ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
||||
<span class="nav-text">Features</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group-title">Opérations</div>
|
||||
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}" title="Modèles ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
<span class="nav-text">Modèles</span>
|
||||
</a>
|
||||
<a href="/classify" class="nav-item {% if active_page == 'classify' %}active{% endif %}" title="Classification SOC">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
<span class="nav-text">Classifier</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Footer -->
|
||||
<div class="px-3 py-3 border-t border-gray-800 shrink-0">
|
||||
<span class="flex items-center gap-2 text-[10px] text-gray-500">
|
||||
<span class="live-dot"></span>
|
||||
<span id="clock"></span>
|
||||
<span id="clock" class="nav-text"></span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Content -->
|
||||
<main class="max-w-[1600px] mx-auto px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ Main Content ═══ -->
|
||||
<div id="main-wrap" class="flex-1 min-h-screen" style="margin-left:220px; transition: margin-left 0.2s ease;">
|
||||
<!-- Page header -->
|
||||
<header class="sticky top-0 z-40 bg-gray-950/80 backdrop-blur border-b border-gray-800">
|
||||
<div class="flex items-center h-12 px-6">
|
||||
<h1 class="text-base font-semibold text-gray-100">{% block page_title %}{% endblock %}</h1>
|
||||
<div class="flex-1"></div>
|
||||
{% block header_actions %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-6 py-5">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Sidebar toggle ──
|
||||
function toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const mw = document.getElementById('main-wrap');
|
||||
sb.classList.toggle('collapsed');
|
||||
mw.style.marginLeft = sb.classList.contains('collapsed') ? '56px' : '220px';
|
||||
}
|
||||
|
||||
// ── Clock ──
|
||||
function updateClock() {
|
||||
document.getElementById('clock').textContent = new Date().toLocaleString('fr-FR');
|
||||
const el = document.getElementById('clock');
|
||||
if (el) el.textContent = new Date().toLocaleString('fr-FR');
|
||||
}
|
||||
updateClock(); setInterval(updateClock, 1000);
|
||||
|
||||
// ── Existing helpers ──
|
||||
// ── Doc tooltip system ──
|
||||
function docToggle(btn) {
|
||||
const panel = btn.nextElementSibling;
|
||||
document.querySelectorAll('.doc-panel.show').forEach(p => { if (p !== panel) p.classList.remove('show'); });
|
||||
panel.classList.toggle('show');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('.doc-btn') && !e.target.closest('.doc-panel'))
|
||||
document.querySelectorAll('.doc-panel.show').forEach(p => p.classList.remove('show'));
|
||||
});
|
||||
|
||||
// ── Number formatting ──
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '—';
|
||||
n = Number(n);
|
||||
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
function fmtPct(n) { return n == null ? '—' : (Number(n)*100).toFixed(1)+'%'; }
|
||||
|
||||
// ── Threat helpers ──
|
||||
function threatBadge(level) {
|
||||
const map = {
|
||||
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
|
||||
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known','ANUBIS_DENY':'badge-critical'
|
||||
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known',
|
||||
'ANUBIS_DENY':'badge-critical','LEGITIMATE_BROWSER':'badge-low'
|
||||
};
|
||||
return `<span class="badge ${map[level]||'badge-normal'}">${level}</span>`;
|
||||
return `<span class="badge ${map[level]||'badge-normal'}">${escapeHtml(level||'')}</span>`;
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Link formatters (all sanitized) ──
|
||||
function fmtIP(ip) {
|
||||
if (!ip) return '';
|
||||
let s = String(ip).replace('::ffff:','');
|
||||
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline">${escapeHtml(s)}</a>`;
|
||||
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline font-mono text-xs">${escapeHtml(s)}</a>`;
|
||||
}
|
||||
function fmtScore(v) {
|
||||
let n = parseFloat(v);
|
||||
if (isNaN(n)) return '-';
|
||||
if (isNaN(n)) return '—';
|
||||
let color = n > 0.7 ? 'text-red-400' : n > 0.4 ? 'text-orange-400' : n > 0.1 ? 'text-yellow-400' : 'text-green-400';
|
||||
return `<span class="${color}">${n.toFixed(4)}</span>`;
|
||||
return `<span class="${color} font-mono">${n.toFixed(4)}</span>`;
|
||||
}
|
||||
|
||||
// ── Navigation helpers ──
|
||||
function fmtASN(org) {
|
||||
if (!org) return '';
|
||||
return `<a href="/detections?asn_org=${encodeURIComponent(org)}" class="text-blue-400 hover:underline cursor-pointer">${escapeHtml(org)}</a>`;
|
||||
return `<a href="/network?asn_org=${encodeURIComponent(org)}" class="text-blue-400 hover:underline cursor-pointer">${escapeHtml(org)}</a>`;
|
||||
}
|
||||
function fmtCountry(cc) {
|
||||
if (!cc) return '';
|
||||
const flags = {'FR':'🇫🇷','DE':'🇩🇪','NL':'🇳🇱','GB':'🇬🇧','ES':'🇪🇸','US':'🇺🇸','RU':'🇷🇺','IT':'🇮🇹','JP':'🇯🇵','CN':'🇨🇳','KR':'🇰🇷','BR':'🇧🇷','AU':'🇦🇺','CA':'🇨🇦','IN':'🇮🇳'};
|
||||
const flags = {'FR':'🇫🇷','DE':'🇩🇪','NL':'🇳🇱','GB':'🇬🇧','ES':'🇪🇸','US':'🇺🇸','RU':'🇷🇺','IT':'🇮🇹','JP':'🇯🇵','CN':'🇨🇳','KR':'🇰🇷','BR':'🇧🇷','AU':'🇦🇺','CA':'🇨🇦','IN':'🇮🇳','SG':'🇸🇬','SE':'🇸🇪','FI':'🇫🇮','IE':'🇮🇪','CH':'🇨🇭'};
|
||||
return `<a href="/detections?country_code=${encodeURIComponent(cc)}" class="hover:underline cursor-pointer">${flags[cc]||'🏳️'} ${escapeHtml(cc)}</a>`;
|
||||
}
|
||||
function fmtJA4(ja4) {
|
||||
if (!ja4) return '';
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-xs" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,20))}…</a>`;
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,22))}…</a>`;
|
||||
}
|
||||
function fmtJA4Full(ja4) {
|
||||
if (!ja4) return '';
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-xs">${escapeHtml(ja4)}</a>`;
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
|
||||
}
|
||||
function fmtBotName(name) {
|
||||
if (!name) return '';
|
||||
@ -164,9 +255,23 @@
|
||||
}
|
||||
function fmtLabel(label) {
|
||||
if (!label) return '';
|
||||
const colors = {human:'text-green-400 bg-green-500/10',datacenter:'text-red-400 bg-red-500/10',hosting:'text-orange-400 bg-orange-500/10'};
|
||||
const colors = {human:'text-green-400 bg-green-500/10',datacenter:'text-red-400 bg-red-500/10',hosting:'text-orange-400 bg-orange-500/10',cdn:'text-cyan-400 bg-cyan-500/10'};
|
||||
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[label]||'text-gray-400 bg-gray-500/10'}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
function fmtDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '—';
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
if (seconds < 3600) return Math.round(seconds/60) + 'min';
|
||||
return (seconds/3600).toFixed(1) + 'h';
|
||||
}
|
||||
function fmtAgo(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
|
||||
if (diff < 60) return 'à l\'instant';
|
||||
if (diff < 3600) return Math.round(diff/60) + ' min';
|
||||
if (diff < 86400) return Math.round(diff/3600) + 'h';
|
||||
return Math.round(diff/86400) + 'j';
|
||||
}
|
||||
|
||||
// ── ECharts helpers ──
|
||||
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
|
||||
@ -176,17 +281,39 @@
|
||||
function ecBase(overrides) {
|
||||
return Object.assign({
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif' },
|
||||
animation: true, animationDuration: 600,
|
||||
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 11 },
|
||||
animation: true, animationDuration: 400,
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
function ecTooltip(extra) {
|
||||
return Object.assign({
|
||||
backgroundColor: '#1f2937', borderColor: '#374151',
|
||||
textStyle: { color: '#e5e7eb', fontSize: 12 },
|
||||
textStyle: { color: '#e5e7eb', fontSize: 11 },
|
||||
confine: true,
|
||||
}, extra);
|
||||
}
|
||||
function ecGrid(extra) {
|
||||
return Object.assign({ left: 50, right: 20, top: 30, bottom: 30 }, extra);
|
||||
}
|
||||
|
||||
// ── Chart resize on window resize ──
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('[_echarts_instance_]').forEach(el => {
|
||||
echarts.getInstanceByDom(el)?.resize();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Generic table builder ──
|
||||
function buildTable(tbody, rows, cols) {
|
||||
tbody.innerHTML = rows.map(r =>
|
||||
'<tr>' + cols.map(c => `<td>${c.fmt ? c.fmt(r[c.key], r) : escapeHtml(r[c.key]??'')}</td>`).join('') + '</tr>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Doc helper: generates a (?) button + panel ──
|
||||
function docHTML(title, body, source) {
|
||||
return `<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">?</button><div class="doc-panel"><h4>${escapeHtml(title)}</h4>${body}<p class="doc-source">Source : ${escapeHtml(source)}</p></div></span>`;
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Classifier{% endblock %}
|
||||
{% block page_title %}
|
||||
Classification SOC
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Feedback analyste SOC</h4>
|
||||
<p>Classifiez les IPs pour entraîner le modèle XGBoost supervisé. Les labels sont utilisés au prochain cycle ML.</p>
|
||||
<p><strong>Bot :</strong> Confirme que l'IP est malveillante. <strong>Légitime :</strong> Faux positif. <strong>Suspect :</strong> À surveiller.</p>
|
||||
<p class="doc-source">Source : soc_feedback → XGBoost training</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<h2 class="text-lg font-semibold text-white">Classification SOC</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Adresse IP</label>
|
||||
|
||||
@ -1,61 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Détections{% endblock %}
|
||||
{% block page_title %}
|
||||
Détections d'anomalies
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Table des détections</h4>
|
||||
<p>Toutes les sessions classées comme anomaliques par l'ensemble ML triple-voix (EIF + Autoencoder + XGBoost). Inclut les bots connus identifiés par dictionnaire.</p>
|
||||
<p><strong>Workflow :</strong> Filtrez par threat level → triez par score → cliquez sur une IP pour l'investiguer → classifiez via le bouton rapide.</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies (30 derniers jours)</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-4">
|
||||
<!-- Summary charts row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 class="text-xs font-medium text-gray-500 mb-2">Détections par threat level</h3>
|
||||
<div id="det-threat-chart" style="height:160px"></div>
|
||||
<div class="space-y-3">
|
||||
<!-- Summary charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Par threat level</span></div>
|
||||
<div class="p-3"><div id="det-threat-chart" style="height:140px"></div></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 class="text-xs font-medium text-gray-500 mb-2">Top 5 raisons de détection</h3>
|
||||
<div id="det-reason-chart" style="height:160px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Top raisons</span></div>
|
||||
<div class="p-3"><div id="det-reason-chart" style="height:140px"></div></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 class="text-xs font-medium text-gray-500 mb-2">Top 5 ASN détectés</h3>
|
||||
<div id="det-asn-chart" style="height:160px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Top ASN détectés</span></div>
|
||||
<div class="p-3"><div id="det-asn-chart" style="height:140px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-white">Anomalies détectées</h2>
|
||||
<div class="flex gap-1.5" id="threat-filters">
|
||||
<button class="filter-btn active" data-filter="">Tous</button>
|
||||
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
|
||||
<button class="filter-btn" data-filter="HIGH">High</button>
|
||||
<button class="filter-btn" data-filter="MEDIUM">Medium</button>
|
||||
<button class="filter-btn" data-filter="KNOWN_BOT">Known Bot</button>
|
||||
<button class="filter-btn" data-filter="ANUBIS_DENY">Anubis Deny</button>
|
||||
<button class="filter-btn" data-filter="ANUBIS_DENY">Anubis</button>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<input type="text" id="search-input" placeholder="Rechercher IP, host..."
|
||||
class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-64 focus:border-brand-500 focus:outline-none">
|
||||
<input type="text" id="search-input" placeholder="Rechercher IP, host…"
|
||||
class="px-3 py-1.5 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-300 w-64 focus:border-brand-500 focus:outline-none">
|
||||
</div>
|
||||
<div id="active-filters" class="flex gap-2 flex-wrap"></div>
|
||||
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
|
||||
<table class="data-table">
|
||||
<!-- Table -->
|
||||
<div class="section-card overflow-hidden">
|
||||
<div class="overflow-x-auto" style="max-height:calc(100vh - 340px); overflow-y:auto">
|
||||
<table class="data-table" id="det-table">
|
||||
<thead><tr>
|
||||
<th style="width:16px"></th>
|
||||
<th class="cursor-pointer" data-sort="detected_at">Date ↕</th>
|
||||
<th>IP</th>
|
||||
<th class="cursor-pointer" data-sort="anomaly_score">Score ↕</th>
|
||||
<th>Raw</th>
|
||||
<th>Threat</th>
|
||||
<th>JA4</th>
|
||||
<th>Host</th>
|
||||
<th>Hits</th>
|
||||
<th class="cursor-pointer" data-sort="hits">Hits ↕</th>
|
||||
<th>ASN</th>
|
||||
<th>Pays</th>
|
||||
<th>Récurrence</th>
|
||||
<th>Raison</th>
|
||||
<th>Bot</th>
|
||||
<th>Réc.</th>
|
||||
</tr></thead>
|
||||
<tbody id="detections-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-t border-gray-800">
|
||||
<span class="text-xs text-gray-500" id="det-info">—</span>
|
||||
<div class="flex gap-2">
|
||||
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">← Précédent</button>
|
||||
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">Suivant →</button>
|
||||
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-xs text-gray-400 hover:text-white disabled:opacity-30">← Précédent</button>
|
||||
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-xs text-gray-400 hover:text-white disabled:opacity-30">Suivant →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,13 +75,12 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'};
|
||||
let dPage=1, dSort='detected_at', dOrder='DESC', dThreat='', dSearch='';
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let dASN=urlParams.get('asn_org')||'',
|
||||
dCountry=urlParams.get('country_code')||'',
|
||||
dJA4=urlParams.get('ja4')||'',
|
||||
dBotName=urlParams.get('bot_name')||'';
|
||||
if(urlParams.get('threat_level')) dThreat=urlParams.get('threat_level');
|
||||
let dASN=urlParams.get('asn_org')||'', dCountry=urlParams.get('country_code')||'',
|
||||
dJA4=urlParams.get('ja4')||'', dBotName=urlParams.get('bot_name')||'';
|
||||
if(urlParams.get('threat_level')) { dThreat=urlParams.get('threat_level'); document.querySelectorAll('[data-filter]').forEach(b=>{b.classList.remove('active');if(b.dataset.filter===dThreat)b.classList.add('active');}); }
|
||||
if(urlParams.get('search')) dSearch=urlParams.get('search');
|
||||
|
||||
function renderActiveFilters() {
|
||||
@ -81,10 +92,10 @@ function renderActiveFilters() {
|
||||
if(dBotName) filters.push({label:'Bot: '+dBotName, clear:()=>{dBotName='';renderActiveFilters();loadDetections();}});
|
||||
el.innerHTML = filters.map((f,i) =>
|
||||
`<span class="inline-flex items-center gap-1 px-2 py-1 bg-brand-500/20 text-brand-400 rounded-lg text-xs">
|
||||
${f.label} <button onclick="window._clearFilter${i}()" class="hover:text-white">✕</button>
|
||||
</span>`).join('');
|
||||
filters.forEach((f,i) => { window['_clearFilter'+i] = f.clear; });
|
||||
${escapeHtml(f.label)} <button onclick="window._cf${i}()" class="hover:text-white">✕</button></span>`).join('');
|
||||
filters.forEach((f,i) => { window['_cf'+i] = f.clear; });
|
||||
}
|
||||
|
||||
async function loadDetections() {
|
||||
const params = new URLSearchParams({page:dPage,per_page:50,sort:dSort,order:dOrder});
|
||||
if(dThreat) params.set('threat_level',dThreat);
|
||||
@ -97,25 +108,31 @@ async function loadDetections() {
|
||||
const r = await fetch('/api/detections?'+params);
|
||||
const d = await r.json();
|
||||
const tbody = document.getElementById('detections-body');
|
||||
tbody.innerHTML = (d.data||[]).map(row => `<tr>
|
||||
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
|
||||
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
|
||||
<td>${fmtScore(row.anomaly_score)}</td>
|
||||
<td>${fmtThreatLink(row.threat_level)}</td>
|
||||
<td class="text-xs font-mono max-w-[120px] truncate">${fmtJA4(row.ja4)}</td>
|
||||
<td class="text-xs max-w-[150px] truncate" title="${row.host||''}">${row.host||''}</td>
|
||||
<td>${row.hits||0}</td>
|
||||
<td class="text-xs max-w-[150px] truncate">${fmtASN(row.asn_org)}</td>
|
||||
<td>${fmtCountry(row.country_code)}</td>
|
||||
<td>${row.recurrence||0}</td>
|
||||
<td class="text-xs max-w-[200px] truncate" title="${row.reason||''}">${row.reason||''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="11" class="text-center text-gray-500 py-8">Aucune détection</td></tr>';
|
||||
tbody.innerHTML = (d.data||[]).map((row, idx) => {
|
||||
const ip = String(row.src_ip||'').replace('::ffff:','');
|
||||
return `<tr onclick="window.location='/ip/${encodeURIComponent(ip)}'" class="group">
|
||||
<td class="text-center"><span class="w-2 h-2 rounded-full inline-block" style="background:${THREAT_COLORS[row.threat_level]||'#6b7280'}"></span></td>
|
||||
<td class="text-[11px] whitespace-nowrap text-gray-400">${(row.detected_at||'').substring(0,16)}</td>
|
||||
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
|
||||
<td>${fmtScore(row.anomaly_score)}</td>
|
||||
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.raw_anomaly_score||0).toFixed(4)}</td>
|
||||
<td>${threatBadge(row.threat_level)}</td>
|
||||
<td>${fmtJA4(row.ja4)}</td>
|
||||
<td class="text-xs max-w-[120px] truncate" title="${escapeHtml(row.host||'')}">${escapeHtml(row.host||'')}</td>
|
||||
<td class="font-mono text-xs">${row.hits||0}</td>
|
||||
<td class="text-xs max-w-[120px] truncate">${fmtASN(row.asn_org)}</td>
|
||||
<td>${fmtCountry(row.country_code)}</td>
|
||||
<td class="text-xs">${row.bot_name ? fmtBotName(row.bot_name) : ''}</td>
|
||||
<td class="text-xs text-center">${row.recurrence||0}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="13" class="text-center text-gray-500 py-8">Aucune détection</td></tr>';
|
||||
const total = d.total||0;
|
||||
document.getElementById('det-info').textContent = `${total} résultats — page ${dPage}/${Math.max(1,Math.ceil(total/50))}`;
|
||||
document.getElementById('det-info').textContent = `${total.toLocaleString()} résultats — page ${dPage}/${Math.max(1,Math.ceil(total/50))}`;
|
||||
document.getElementById('prev-btn').disabled = dPage <= 1;
|
||||
document.getElementById('next-btn').disabled = dPage * 50 >= total;
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
document.getElementById('prev-btn').onclick = () => { if(dPage>1){dPage--;loadDetections();} };
|
||||
document.getElementById('next-btn').onclick = () => { dPage++;loadDetections(); };
|
||||
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
|
||||
@ -135,27 +152,21 @@ document.getElementById('search-input').oninput = (e) => {
|
||||
};
|
||||
loadDetections();
|
||||
renderActiveFilters();
|
||||
// Summary mini-charts (loaded once)
|
||||
|
||||
async function loadDetSummary() {
|
||||
try {
|
||||
const r = await fetch('/api/detections?per_page=500');
|
||||
const d = await r.json();
|
||||
const rows = d.data || [];
|
||||
// Threat distribution
|
||||
const threatCounts = {};
|
||||
const reasonCounts = {};
|
||||
const asnCounts = {};
|
||||
const threatCounts={}, reasonCounts={}, asnCounts={};
|
||||
rows.forEach(row => {
|
||||
threatCounts[row.threat_level] = (threatCounts[row.threat_level]||0)+1;
|
||||
if (row.reason) { const short = row.reason.substring(0,40); reasonCounts[short] = (reasonCounts[short]||0)+1; }
|
||||
if (row.asn_org) asnCounts[row.asn_org] = (asnCounts[row.asn_org]||0)+1;
|
||||
});
|
||||
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'};
|
||||
// Threat pie
|
||||
const ch1 = echarts.init(document.getElementById('det-threat-chart'));
|
||||
ch1.setOption(ecBase({tooltip:ecTooltip({trigger:'item'}),series:[{type:'pie',radius:['30%','65%'],label:{color:EC_TEXT,fontSize:10},
|
||||
ch1.setOption(ecBase({tooltip:ecTooltip({trigger:'item'}),series:[{type:'pie',radius:['35%','70%'],label:{color:EC_TEXT,fontSize:10},
|
||||
data:Object.entries(threatCounts).map(([k,v])=>({name:k,value:v,itemStyle:{color:THREAT_COLORS[k]||'#6b7280'}}))}]}));
|
||||
// Reason bar
|
||||
const topReasons = Object.entries(reasonCounts).sort((a,b)=>b[1]-a[1]).slice(0,5);
|
||||
if (topReasons.length) {
|
||||
const ch2 = echarts.init(document.getElementById('det-reason-chart'));
|
||||
@ -164,7 +175,6 @@ async function loadDetSummary() {
|
||||
xAxis:{type:'value',show:false},
|
||||
series:[{type:'bar',data:topReasons.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#6366f1'},label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
|
||||
}
|
||||
// ASN bar
|
||||
const topASN = Object.entries(asnCounts).sort((a,b)=>b[1]-a[1]).slice(0,5);
|
||||
if (topASN.length) {
|
||||
const ch3 = echarts.init(document.getElementById('det-asn-chart'));
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Features ML{% endblock %}
|
||||
{% block page_title %}
|
||||
Features ML
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Exploration des features</h4>
|
||||
<p>Visualisez les 72 features ML extraites : comportementales (velocity, fuzzing), réseau (port_density, JA4), et thesis §5 (entropie, cadence, drift).</p>
|
||||
<p><strong>Radar :</strong> Compare les profils ISP (humain) vs datacenter (bot). <strong>Scatter :</strong> Identifiez visuellement les clusters anormaux.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h, view_thesis_features_1h</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold text-white">Features ML — Exploration</h2>
|
||||
|
||||
<!-- Row 1: Radar + Feature Importance -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
@ -1,70 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — IP {{ ip }}{% endblock %}
|
||||
{% block page_title %}
|
||||
<a href="/detections" class="text-gray-500 hover:text-gray-300 text-xs">← Retour</a>
|
||||
<span class="mx-2 text-gray-700">/</span>
|
||||
Investigation IP : <span class="text-brand-500 font-mono">{{ ip }}</span>
|
||||
{% endblock %}
|
||||
{% block header_actions %}
|
||||
<div class="flex gap-2">
|
||||
<select id="cls-select" class="px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs text-gray-300">
|
||||
<option value="bot">🤖 Bot</option><option value="legitimate">✅ Légitime</option><option value="suspicious">⚠️ Suspect</option>
|
||||
</select>
|
||||
<input type="text" id="cls-comment" placeholder="Commentaire…" class="px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs text-gray-300 w-40 focus:border-brand-500 focus:outline-none">
|
||||
<button id="cls-btn" class="px-3 py-1 bg-brand-600 text-white rounded text-xs font-medium hover:bg-brand-500">Classifier</button>
|
||||
<span id="cls-result" class="text-xs self-center"></span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/detections" class="text-gray-500 hover:text-gray-300">← Retour</a>
|
||||
<h2 class="text-lg font-semibold text-white">Investigation IP : <span class="text-brand-500">{{ ip }}</span></h2>
|
||||
</div>
|
||||
<!-- KPI Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Détections</div><div class="text-xl font-bold text-red-400" id="ip-det-count">—</div></div>
|
||||
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Pire score</div><div class="text-xl font-bold" id="ip-worst-score">—</div></div>
|
||||
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Récurrence</div><div class="text-xl font-bold text-yellow-400" id="ip-recurrence">—</div></div>
|
||||
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Requêtes HTTP</div><div class="text-xl font-bold text-gray-200" id="ip-http-count">—</div></div>
|
||||
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Scores ML</div><div class="text-xl font-bold text-brand-500" id="ip-score-count">—</div></div>
|
||||
<div class="space-y-4">
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Détections</div><div class="text-xl font-bold text-red-400" id="ip-det-count">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Pire score</div><div class="text-xl font-bold" id="ip-worst-score">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Récurrence</div><div class="text-xl font-bold text-yellow-400" id="ip-recurrence">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Requêtes HTTP</div><div class="text-xl font-bold text-gray-200" id="ip-http-count">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Scores ML</div><div class="text-xl font-bold text-brand-500" id="ip-score-count">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">ASN / Pays</div><div class="text-sm font-medium" id="ip-asn-info">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row: Radar + Score timeline -->
|
||||
<!-- Radar + Score timeline -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Profil comportemental (vs baseline)</h3>
|
||||
<div id="radar-chart" style="height:320px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Profil comportemental
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Radar comportemental</h4>
|
||||
<p>Compare cette IP aux profils moyens ISP (vert) et datacenter/bot (rouge). Les axes sont normalisés 0→1.</p>
|
||||
<p><strong>Interprétation :</strong> Un profil proche du rouge indique un comportement bot. hit_velocity élevé + fuzzing élevé = scraping agressif.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body"><div id="radar-chart" style="height:300px"></div></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Scores ML dans le temps</h3>
|
||||
<div id="score-chart" style="height:320px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Scores ML dans le temps
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Historique des scores</h4>
|
||||
<p>Évolution du score ML normalisé sur les derniers cycles. Un score stable élevé = bot persistant. Un pic soudain = changement de comportement.</p>
|
||||
<p class="doc-source">Source : ml_all_scores</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body"><div id="score-chart" style="height:300px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detections table -->
|
||||
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Détections</h3>
|
||||
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
|
||||
<!-- Detections -->
|
||||
<div class="section-card overflow-hidden">
|
||||
<div class="section-header"><span class="section-title">Détections
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Historique des détections</h4>
|
||||
<p>Chaque ligne = une session classée comme anormale. Le score combiné utilise l'ensemble triple-voix (EIF + AE + XGBoost).</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>Date</th><th>Score</th><th>Raw</th><th>Threat</th><th>JA4</th><th>Host</th><th>Hits</th><th>Raison</th>
|
||||
<th>Date</th><th>Score</th><th>Raw</th><th>AE</th><th>XGB</th><th>Threat</th><th>JA4</th><th>Host</th><th>Hits</th><th>Campagne</th>
|
||||
</tr></thead><tbody id="det-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Features grid -->
|
||||
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden" id="features-section" style="display:none">
|
||||
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Features AI</h3>
|
||||
<div class="p-5 grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3 text-sm" id="features-grid"></div>
|
||||
<div class="section-card overflow-hidden" id="features-section" style="display:none">
|
||||
<div class="section-header"><span class="section-title">Features AI (dernière fenêtre)
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Features ML détaillées</h4>
|
||||
<p>72 features extraites : velocity, fuzzing, entropie, ratios, métriques TLS, etc. Valeurs élevées en rouge indiquent un comportement suspect.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="p-4 grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2 text-sm" id="features-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- HTTP Logs -->
|
||||
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Dernières requêtes HTTP (100 max)</h3>
|
||||
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
|
||||
<div class="section-card overflow-hidden">
|
||||
<div class="section-header"><span class="section-title">Requêtes HTTP récentes (100 max)
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Logs HTTP bruts</h4>
|
||||
<p>Dernières requêtes de cette IP. Cherchez des patterns : scraping séquentiel, POST répétés, paths suspects, absence de referer.</p>
|
||||
<p class="doc-source">Source : http_logs</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>Time</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP Ver</th><th>User-Agent</th><th>JA4</th>
|
||||
<th>Time</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP</th><th>User-Agent</th><th>JA4</th>
|
||||
</tr></thead><tbody id="http-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classify -->
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Classifier cette IP</h3>
|
||||
<div class="flex gap-3 items-center">
|
||||
<select id="cls-select" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300">
|
||||
<option value="bot">🤖 Bot</option><option value="legitimate">✅ Légitime</option><option value="suspicious">⚠️ Suspect</option>
|
||||
</select>
|
||||
<input type="text" id="cls-comment" placeholder="Commentaire (optionnel)" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none">
|
||||
<button id="cls-btn" class="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700">Envoyer</button>
|
||||
</div>
|
||||
<div id="cls-result" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
@ -94,37 +124,38 @@ async function loadIP() {
|
||||
document.getElementById('ip-recurrence').textContent = d.recurrence[0].recurrence || 0;
|
||||
document.getElementById('ip-worst-score').innerHTML = fmtScore(d.recurrence[0].worst_score);
|
||||
}
|
||||
// ASN info from first detection or score
|
||||
const firstRow = d.detections?.[0] || d.scores?.[0] || {};
|
||||
if (firstRow.asn_org || firstRow.country_code) {
|
||||
document.getElementById('ip-asn-info').innerHTML =
|
||||
(firstRow.asn_org ? fmtASN(firstRow.asn_org) : '') +
|
||||
(firstRow.country_code ? ' ' + fmtCountry(firstRow.country_code) : '');
|
||||
}
|
||||
|
||||
// Radar chart
|
||||
// Radar
|
||||
if (radar.features?.length && Object.keys(radar.ip_values).length) {
|
||||
const labels = radar.features.map(f => f.replace('_',' '));
|
||||
const labels = radar.features.map(f => f.replace(/_/g,' '));
|
||||
const ipVals = radar.features.map(f => radar.ip_values[f] ?? 0);
|
||||
const humanVals = radar.features.map(f => radar.human_baseline[f] ?? 0);
|
||||
const botVals = radar.features.map(f => radar.bot_baseline[f] ?? 0);
|
||||
// Normalize to 0-1
|
||||
const maxVals = radar.features.map((f,i) => Math.max(ipVals[i], humanVals[i], botVals[i], 0.001));
|
||||
const norm = (arr) => arr.map((v,i) => +(v/maxVals[i]).toFixed(3));
|
||||
|
||||
const maxVals = radar.features.map((_,i) => Math.max(ipVals[i], humanVals[i], botVals[i], 0.001));
|
||||
const norm = arr => arr.map((v,i) => +(v/maxVals[i]).toFixed(3));
|
||||
const ch = initChart('radar-chart');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({}),
|
||||
legend: {data:['Cette IP','Humain moyen','Bot moyen'], bottom:0, textStyle:{color:EC_TEXT,fontSize:11}},
|
||||
legend: {data:['Cette IP','ISP moyen','Bot moyen'], bottom:0, textStyle:{color:EC_TEXT,fontSize:10}},
|
||||
radar: {
|
||||
indicator: labels.map((l,i) => ({name:l, max:1})),
|
||||
shape:'polygon',
|
||||
indicator: labels.map(() => ({max:1})),
|
||||
shape:'polygon', radius:'65%',
|
||||
splitArea:{areaStyle:{color:['rgba(99,102,241,0.02)','rgba(99,102,241,0.04)']}},
|
||||
splitLine:{lineStyle:{color:EC_GRID}},
|
||||
axisLine:{lineStyle:{color:EC_GRID}},
|
||||
axisName:{color:EC_TEXT,fontSize:10},
|
||||
splitLine:{lineStyle:{color:EC_GRID}}, axisLine:{lineStyle:{color:EC_GRID}},
|
||||
axisName:{color:EC_TEXT,fontSize:9,formatter:(_,i)=>labels[i.dimensionIndex]||''},
|
||||
},
|
||||
series: [{
|
||||
type:'radar',
|
||||
data: [
|
||||
{value:norm(ipVals), name:'Cette IP', lineStyle:{color:'#f97316',width:2}, areaStyle:{color:'rgba(249,115,22,0.15)'}, itemStyle:{color:'#f97316'}},
|
||||
{value:norm(humanVals), name:'Humain moyen', lineStyle:{color:'#22c55e',width:1,type:'dashed'}, areaStyle:{color:'rgba(34,197,94,0.05)'}, itemStyle:{color:'#22c55e'}},
|
||||
{value:norm(botVals), name:'Bot moyen', lineStyle:{color:'#ef4444',width:1,type:'dashed'}, areaStyle:{color:'rgba(239,68,68,0.05)'}, itemStyle:{color:'#ef4444'}},
|
||||
]
|
||||
}]
|
||||
series: [{type:'radar', data: [
|
||||
{value:norm(ipVals), name:'Cette IP', lineStyle:{color:'#f97316',width:2}, areaStyle:{color:'rgba(249,115,22,0.15)'}, itemStyle:{color:'#f97316'}},
|
||||
{value:norm(humanVals), name:'ISP moyen', lineStyle:{color:'#22c55e',width:1,type:'dashed'}, areaStyle:{color:'rgba(34,197,94,0.05)'}, itemStyle:{color:'#22c55e'}},
|
||||
{value:norm(botVals), name:'Bot moyen', lineStyle:{color:'#ef4444',width:1,type:'dashed'}, areaStyle:{color:'rgba(239,68,68,0.05)'}, itemStyle:{color:'#ef4444'}},
|
||||
]}]
|
||||
}));
|
||||
}
|
||||
|
||||
@ -134,8 +165,8 @@ async function loadIP() {
|
||||
const ch = initChart('score-chart');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'axis'}),
|
||||
grid: {left:50,right:20,top:20,bottom:30},
|
||||
xAxis: {type:'category', data:scores.map(s=>(s.detected_at||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT}},
|
||||
grid: ecGrid(),
|
||||
xAxis: {type:'category', data:scores.map(s=>(s.detected_at||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||
yAxis: {type:'value', min:0, max:1, splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
||||
series: [{
|
||||
type:'line', data:scores.map(s=>s.anomaly_score), smooth:true,
|
||||
@ -147,62 +178,61 @@ async function loadIP() {
|
||||
|
||||
// Detections table
|
||||
document.getElementById('det-body').innerHTML = (d.detections||[]).map(row => `<tr>
|
||||
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
|
||||
<td class="text-[11px] whitespace-nowrap text-gray-400">${(row.detected_at||'').substring(0,16)}</td>
|
||||
<td>${fmtScore(row.anomaly_score)}</td>
|
||||
<td>${fmtScore(row.raw_anomaly_score)}</td>
|
||||
<td>${fmtThreatLink(row.threat_level)}</td>
|
||||
<td class="text-xs font-mono max-w-[100px] truncate">${fmtJA4(row.ja4)}</td>
|
||||
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
|
||||
<td>${row.hits||0}</td>
|
||||
<td class="text-xs max-w-[200px] truncate">${row.reason||''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Aucune détection</td></tr>';
|
||||
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.raw_anomaly_score||0).toFixed(4)}</td>
|
||||
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.ae_recon_error||0).toFixed(4)}</td>
|
||||
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.xgb_prob||0).toFixed(4)}</td>
|
||||
<td>${threatBadge(row.threat_level)}</td>
|
||||
<td>${fmtJA4(row.ja4)}</td>
|
||||
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
|
||||
<td class="font-mono text-xs">${row.hits||0}</td>
|
||||
<td class="text-[11px] text-purple-400">${row.campaign_id && row.campaign_id!=='0' ? '#'+escapeHtml(String(row.campaign_id).substring(0,8)) : ''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="10" class="text-center text-gray-500 py-4">Aucune détection</td></tr>';
|
||||
|
||||
// AI Features
|
||||
if (d.ai_features?.length) {
|
||||
const f = d.ai_features[0];
|
||||
const grid = document.getElementById('features-grid');
|
||||
const skip = new Set(['src_ip','window_start','ja4','host','bot_name','src_ip_str']);
|
||||
grid.innerHTML = Object.entries(f).filter(([k])=>!skip.has(k)).map(([k,v]) => {
|
||||
document.getElementById('features-grid').innerHTML = Object.entries(f).filter(([k])=>!skip.has(k)).map(([k,v]) => {
|
||||
let val = typeof v === 'number' ? v.toFixed(4) : v;
|
||||
let color = 'text-gray-200';
|
||||
if (typeof v === 'number' && v > 0.7) color = 'text-red-400';
|
||||
else if (typeof v === 'number' && v > 0.4) color = 'text-orange-400';
|
||||
let display = `<span class="text-sm ${color} font-mono">${val}</span>`;
|
||||
if (k === 'asn_org' && v) display = `<span class="text-sm">${fmtASN(v)}</span>`;
|
||||
else if (k === 'country_code' && v) display = `<span class="text-sm">${fmtCountry(v)}</span>`;
|
||||
else if (k === 'asn_label' && v) display = `<span class="text-sm">${fmtLabel(v)}</span>`;
|
||||
return `<div class="bg-gray-800 rounded-lg p-2"><div class="text-[10px] text-gray-500 truncate">${k}</div>${display}</div>`;
|
||||
let display = `<span class="${color} font-mono text-xs">${val}</span>`;
|
||||
if (k === 'asn_org' && v) display = fmtASN(v);
|
||||
else if (k === 'country_code' && v) display = fmtCountry(v);
|
||||
else if (k === 'asn_label' && v) display = fmtLabel(v);
|
||||
return `<div class="bg-gray-800/50 rounded p-2"><div class="text-[9px] text-gray-500 truncate">${escapeHtml(k)}</div>${display}</div>`;
|
||||
}).join('');
|
||||
document.getElementById('features-section').style.display = '';
|
||||
}
|
||||
|
||||
// HTTP logs
|
||||
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
|
||||
<td class="text-xs whitespace-nowrap">${row.time||''}</td>
|
||||
<td class="font-mono text-xs">${row.method||''}</td>
|
||||
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
|
||||
<td class="text-xs max-w-[250px] truncate font-mono" title="${row.path||''}">${row.path||''}</td>
|
||||
<td class="font-mono text-xs">${row.http_version||''}</td>
|
||||
<td class="text-xs max-w-[200px] truncate">${row.header_user_agent||''}</td>
|
||||
<td class="text-xs font-mono">${row.ja4||''}</td>
|
||||
<td class="text-[11px] whitespace-nowrap text-gray-400">${row.time||''}</td>
|
||||
<td><span class="font-mono text-xs ${row.method==='POST'?'text-orange-400':'text-gray-300'}">${escapeHtml(row.method||'')}</span></td>
|
||||
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
|
||||
<td class="text-xs max-w-[200px] truncate font-mono" title="${escapeHtml(row.path||'')}">${escapeHtml(row.path||'')}</td>
|
||||
<td class="font-mono text-[11px] text-gray-400">${escapeHtml(row.http_version||'')}</td>
|
||||
<td class="text-xs max-w-[180px] truncate text-gray-400">${escapeHtml(row.header_user_agent||'')}</td>
|
||||
<td class="font-mono text-[11px]">${escapeHtml(row.ja4||'')}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="7" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
|
||||
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
// Classify button
|
||||
document.getElementById('cls-btn').onclick = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({src_ip:IP, classification:document.getElementById('cls-select').value, comment:document.getElementById('cls-comment').value})});
|
||||
const d = await r.json();
|
||||
document.getElementById('cls-result').innerHTML = r.ok
|
||||
? `<span class="text-green-400">✓ Classifié : ${d.classification}</span>`
|
||||
: `<span class="text-red-400">✗ Erreur : ${d.detail||'unknown'}</span>`;
|
||||
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ ${e}</span>`; }
|
||||
? `<span class="text-green-400">✓ ${escapeHtml(d.classification)}</span>`
|
||||
: `<span class="text-red-400">✗ ${escapeHtml(d.detail||'erreur')}</span>`;
|
||||
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ erreur</span>`; }
|
||||
};
|
||||
|
||||
loadIP();
|
||||
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Modèles{% endblock %}
|
||||
{% block page_title %}
|
||||
Modèles ML
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>État des modèles ML</h4>
|
||||
<p>Ensemble triple-voix : Extended Isolation Forest (EIF) + Autoencoder (AE) + XGBoost supervisé.</p>
|
||||
<p><strong>Versions :</strong> 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.</p>
|
||||
<p class="doc-source">Source : /data/models/*.json, ml_all_scores</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold text-white">État des modèles ML</h2>
|
||||
<!-- Scoring stats from ClickHouse -->
|
||||
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Statistiques de scoring (7 derniers jours)</h3>
|
||||
|
||||
@ -1,95 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Analyse Réseau{% endblock %}
|
||||
{% block page_title %}
|
||||
Analyse Réseau
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Analyse réseau</h4>
|
||||
<p>Vue complète de l'infrastructure réseau : ASN, pays, fingerprints JA4, rotation de fingerprints, brute-force et menaces persistantes.</p>
|
||||
<p><strong>Workflow :</strong> Identifiez les ASN suspects → vérifiez la rotation JA4 → contrôlez le brute-force → investiguez les IPs récurrentes.</p>
|
||||
<p class="doc-source">Sources : view_ai_features_1h, view_host_ip_ja4_rotation, view_form_bruteforce_detected, view_ip_recurrence</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-lg font-semibold text-white">Analyse Réseau</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- KPI Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4" id="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-indigo-400"></span><span class="text-xs text-gray-500">Pays</span></div>
|
||||
<div class="text-2xl font-bold text-brand-500" id="kpi-countries">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span><span class="text-xs text-gray-500">ASNs</span></div>
|
||||
<div class="text-2xl font-bold text-yellow-400" id="kpi-asns">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-green-400"></span><span class="text-xs text-gray-500">Sessions humaines</span></div>
|
||||
<div class="text-2xl font-bold text-green-400" id="kpi-human">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-red-400"></span><span class="text-xs text-gray-500">Sessions datacenter</span></div>
|
||||
<div class="text-2xl font-bold text-red-400" id="kpi-datacenter">—</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Pays</div><div class="text-xl font-bold text-brand-500" id="kpi-countries">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">ASNs</div><div class="text-xl font-bold text-yellow-400" id="kpi-asns">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions ISP</div><div class="text-xl font-bold text-green-400" id="kpi-human">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions DC</div><div class="text-xl font-bold text-red-400" id="kpi-datacenter">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Rotation JA4</div><div class="text-xl font-bold text-purple-400" id="kpi-rotation">—</div></div>
|
||||
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Brute-force</div><div class="text-xl font-bold text-red-400" id="kpi-brute">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: ASN Treemap + Country Sunburst -->
|
||||
<!-- Row: ASN Treemap + Sunburst -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Treemap ASN (par label)</h3>
|
||||
<div id="chart-treemap" style="height:380px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Treemap ASN
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Treemap ASN</h4>
|
||||
<p>Taille = nombre de sessions. Regroupé par type : <span class="text-green-400">ISP</span> (résidentiel), <span class="text-red-400">Datacenter</span>, <span class="text-orange-400">Hosting</span>, <span class="text-cyan-400">CDN</span>.</p>
|
||||
<p><strong>Action :</strong> Un ASN datacenter avec beaucoup de sessions mérite investigation.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body"><div id="chart-treemap" style="height:340px"></div></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Sunburst Pays → Label</h3>
|
||||
<div id="chart-sunburst" style="height:380px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Pays → Type ASN
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Sunburst géographique</h4>
|
||||
<p>Niveau 1 : pays. Niveau 2 : type d'ASN. Identifiez les pays avec forte proportion datacenter.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body"><div id="chart-sunburst" style="height:340px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: JA4 fingerprint table -->
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Empreintes JA4</h3>
|
||||
<div class="overflow-x-auto" style="max-height:420px; overflow-y:auto">
|
||||
<table class="data-table" id="ja4-table">
|
||||
<thead><tr>
|
||||
<th class="cursor-pointer select-none" data-col="0">JA4 ▸</th>
|
||||
<th class="cursor-pointer select-none" data-col="1">Sessions ▸</th>
|
||||
<th class="cursor-pointer select-none" data-col="2">Hits ▸</th>
|
||||
<th class="cursor-pointer select-none" data-col="3">Avg Velocity ▸</th>
|
||||
<th class="cursor-pointer select-none" data-col="4">Avg Fuzz ▸</th>
|
||||
<th class="cursor-pointer select-none" data-col="5">Browser Score ▸</th>
|
||||
<th>Label</th>
|
||||
<th>Bot</th>
|
||||
</tr></thead>
|
||||
<tbody id="ja4-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: ASN detail table + Bot pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2 bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Détail ASN</h3>
|
||||
<div class="overflow-x-auto" style="max-height:400px; overflow-y:auto">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>ASN Org</th><th>Label</th><th>Pays</th>
|
||||
<th>Sessions</th><th>Hits</th><th>Avg Velocity</th><th>Avg Fuzz</th>
|
||||
</tr></thead>
|
||||
<tbody id="asn-body"></tbody>
|
||||
</table>
|
||||
<!-- Row: JA4 Rotation + Brute-force -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- JA4 Rotation -->
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Rotation JA4 (évasion TLS)
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Rotation de fingerprints JA4</h4>
|
||||
<p>IPs utilisant plusieurs fingerprints TLS distinctes sur une fenêtre horaire. Indique une tentative d'évasion de détection (rotation de client TLS).</p>
|
||||
<p><strong>Seuil critique :</strong> ≥ 3 JA4 distincts par IP/host/heure.</p>
|
||||
<p class="doc-source">Source : view_host_ip_ja4_rotation</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-x-auto" style="max-height:300px; overflow-y:auto">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>IP</th><th>Host</th><th>JA4 distincts</th><th>Hits</th><th>Fenêtre</th>
|
||||
</tr></thead><tbody id="rotation-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Bots par empreinte</h3>
|
||||
<div id="chart-botpie" style="height:340px"></div>
|
||||
<!-- Brute-force -->
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">
|
||||
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
Brute-force / Credential stuffing
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Détection brute-force</h4>
|
||||
<p>IPs envoyant ≥10 requêtes POST par host sur 24h. Indique du credential stuffing ou du brute-force de formulaires.</p>
|
||||
<p class="doc-source">Source : view_form_bruteforce_detected</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-x-auto" style="max-height:300px; overflow-y:auto">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>IP</th><th>Host</th><th>POSTs</th><th>Paths</th><th>Première</th><th>Dernière</th>
|
||||
</tr></thead><tbody id="brute-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Persistent threats + JA4 table -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Persistent threats -->
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Menaces persistantes
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>IPs récurrentes</h4>
|
||||
<p>IPs détectées sur plusieurs fenêtres horaires. Récurrence élevée = acteur persistant. Le score indique le pire score observé.</p>
|
||||
<p class="doc-source">Source : view_ip_recurrence</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-y-auto" style="max-height:360px">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>IP</th><th>Réc.</th><th>Score</th><th>Threat</th>
|
||||
</tr></thead><tbody id="recurrence-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JA4 Fingerprints -->
|
||||
<div class="lg:col-span-2 section-card">
|
||||
<div class="section-header"><span class="section-title">Empreintes JA4
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Fingerprints TLS (JA4)</h4>
|
||||
<p>Chaque combinaison unique de paramètres TLS génère un hash JA4. Les navigateurs courants partagent des fingerprints connues.</p>
|
||||
<p><strong>Indicateurs :</strong> Velocity élevée + browser score bas = bot probable.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h GROUP BY ja4</p>
|
||||
</div></span>
|
||||
</span></div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-x-auto" style="max-height:360px; overflow-y:auto">
|
||||
<table class="data-table" id="ja4-table"><thead><tr>
|
||||
<th class="cursor-pointer" data-col="0">JA4</th>
|
||||
<th class="cursor-pointer" data-col="1">Sessions</th>
|
||||
<th class="cursor-pointer" data-col="2">Hits</th>
|
||||
<th class="cursor-pointer" data-col="3">Velocity</th>
|
||||
<th class="cursor-pointer" data-col="4">Fuzz</th>
|
||||
<th class="cursor-pointer" data-col="5">Browser</th>
|
||||
<th>Label</th><th>Bot</th>
|
||||
</tr></thead><tbody id="ja4-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: ASN table + Bot pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2 section-card">
|
||||
<div class="section-header"><span class="section-title">Détail ASN</span></div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-x-auto" style="max-height:360px; overflow-y:auto">
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>ASN Org</th><th>Label</th><th>Pays</th><th>Sessions</th><th>Hits</th><th>Velocity</th><th>Fuzz</th>
|
||||
</tr></thead><tbody id="asn-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-header"><span class="section-title">Bots par empreinte</span></div>
|
||||
<div class="section-body"><div id="chart-botpie" style="height:300px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const LABEL_COLORS = {human:'#22c55e', datacenter:'#ef4444', hosting:'#f97316', unknown:'#6b7280'};
|
||||
|
||||
function labelBadge(label) {
|
||||
const colors = {
|
||||
human: 'bg-green-500/20 text-green-400',
|
||||
datacenter: 'bg-red-500/20 text-red-400',
|
||||
hosting: 'bg-orange-500/20 text-orange-400',
|
||||
unknown: 'bg-gray-500/20 text-gray-400',
|
||||
};
|
||||
return `<span class="badge ${colors[label] || colors.unknown}">${label || 'unknown'}</span>`;
|
||||
}
|
||||
const LABEL_COLORS = {isp:'#22c55e', datacenter:'#ef4444', hosting:'#f97316', cdn:'#06b6d4', unknown:'#6b7280'};
|
||||
|
||||
let charts = {};
|
||||
function initChart(id) {
|
||||
@ -100,178 +168,147 @@ function initChart(id) {
|
||||
return charts[id];
|
||||
}
|
||||
|
||||
/* ── Sortable JA4 table ── */
|
||||
let ja4Rows = [];
|
||||
let sortCol = 1, sortAsc = false;
|
||||
|
||||
let ja4Rows = [], sortCol = 1, sortAsc = false;
|
||||
function renderJA4Table() {
|
||||
const sorted = [...ja4Rows].sort((a,b) => {
|
||||
const va = a[sortCol], vb = b[sortCol];
|
||||
if (typeof va === 'number' && typeof vb === 'number') return sortAsc ? va - vb : vb - va;
|
||||
if (typeof va === 'number') return sortAsc ? va-vb : vb-va;
|
||||
return sortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
document.getElementById('ja4-body').innerHTML = sorted.map(r =>
|
||||
`<tr>
|
||||
<td class="font-mono text-xs">${fmtJA4Full(r[0])}</td>
|
||||
<td>${r[1]}</td><td>${r[2]}</td>
|
||||
<td>${r[3].toFixed(3)}</td><td>${r[4].toFixed(3)}</td><td>${r[5].toFixed(2)}</td>
|
||||
<td>${fmtLabel(r[6])}</td>
|
||||
<td class="text-xs">${fmtBotName(r[7])}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
`<tr onclick="window.location='/detections?ja4=${encodeURIComponent(r[0])}'">
|
||||
<td class="font-mono text-[11px]">${fmtJA4Full(r[0])}</td>
|
||||
<td>${r[1]}</td><td>${r[2]}</td><td>${r[3].toFixed(3)}</td><td>${r[4].toFixed(3)}</td><td>${r[5].toFixed(2)}</td>
|
||||
<td>${fmtLabel(r[6])}</td><td class="text-xs">${fmtBotName(r[7])}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('ja4-table').querySelector('thead').addEventListener('click', e => {
|
||||
const th = e.target.closest('th[data-col]');
|
||||
if (!th) return;
|
||||
const th = e.target.closest('th[data-col]'); if(!th) return;
|
||||
const col = parseInt(th.dataset.col);
|
||||
if (col === sortCol) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = false; }
|
||||
if(col===sortCol) sortAsc=!sortAsc; else { sortCol=col; sortAsc=false; }
|
||||
renderJA4Table();
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [geo, fp] = await Promise.all([
|
||||
fetch('/api/geo').then(r => r.json()),
|
||||
fetch('/api/fingerprints').then(r => r.json()),
|
||||
const [geo, fp, rot, bf, rec] = await Promise.all([
|
||||
fetch('/api/geo').then(r=>r.json()),
|
||||
fetch('/api/fingerprints').then(r=>r.json()),
|
||||
fetch('/api/ja4-rotation').then(r=>r.json()),
|
||||
fetch('/api/brute-force').then(r=>r.json()),
|
||||
fetch('/api/recurrence').then(r=>r.json()),
|
||||
]);
|
||||
|
||||
const countries = geo.countries || [];
|
||||
const asns = geo.asns || [];
|
||||
const ja4Stats = fp.ja4_stats || [];
|
||||
const botJa4 = fp.bot_ja4 || [];
|
||||
const countries = geo.countries||[], asns = geo.asns||[];
|
||||
const ja4Stats = fp.ja4_stats||[], botJa4 = fp.bot_ja4||[];
|
||||
const rotData = rot.data||[], bfData = bf.data||[], recData = rec.data||[];
|
||||
|
||||
// ── KPIs ──
|
||||
const uniqueCountries = new Set(countries.map(c => c.country_code)).size;
|
||||
const uniqueAsns = new Set(asns.map(a => a.asn_org)).size;
|
||||
const humanSessions = asns.filter(a => a.asn_label === 'human').reduce((s,a) => s + (a.sessions||0), 0);
|
||||
const dcSessions = asns.filter(a => a.asn_label === 'datacenter').reduce((s,a) => s + (a.sessions||0), 0);
|
||||
document.getElementById('kpi-countries').textContent = uniqueCountries.toLocaleString();
|
||||
document.getElementById('kpi-asns').textContent = uniqueAsns.toLocaleString();
|
||||
document.getElementById('kpi-human').textContent = humanSessions.toLocaleString();
|
||||
document.getElementById('kpi-datacenter').textContent = dcSessions.toLocaleString();
|
||||
// KPIs
|
||||
document.getElementById('kpi-countries').textContent = new Set(countries.map(c=>c.country_code)).size;
|
||||
document.getElementById('kpi-asns').textContent = new Set(asns.map(a=>a.asn_org)).size;
|
||||
document.getElementById('kpi-human').textContent = fmtNum(asns.filter(a=>a.asn_label==='isp').reduce((s,a)=>s+(a.sessions||0),0));
|
||||
document.getElementById('kpi-datacenter').textContent = fmtNum(asns.filter(a=>a.asn_label==='datacenter').reduce((s,a)=>s+(a.sessions||0),0));
|
||||
document.getElementById('kpi-rotation').textContent = rotData.length;
|
||||
document.getElementById('kpi-brute').textContent = bfData.length;
|
||||
|
||||
// ── ASN Treemap grouped by asn_label ──
|
||||
// ASN Treemap
|
||||
const treemapChart = initChart('chart-treemap');
|
||||
if (treemapChart && asns.length) {
|
||||
const byLabel = {};
|
||||
asns.forEach(a => {
|
||||
const lbl = a.asn_label || 'unknown';
|
||||
if (!byLabel[lbl]) byLabel[lbl] = {name:lbl, value:0, children:[], itemStyle:{color:LABEL_COLORS[lbl]||'#6b7280'}};
|
||||
const lbl = a.asn_label||'unknown';
|
||||
if(!byLabel[lbl]) byLabel[lbl] = {name:lbl, value:0, children:[], itemStyle:{color:LABEL_COLORS[lbl]||'#6b7280'}};
|
||||
byLabel[lbl].children.push({name:a.asn_org, value:a.sessions||0});
|
||||
byLabel[lbl].value += a.sessions || 0;
|
||||
byLabel[lbl].value += a.sessions||0;
|
||||
});
|
||||
treemapChart.setOption(ecBase({
|
||||
tooltip: ecTooltip({formatter: i => `${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
|
||||
series:[{
|
||||
type:'treemap',
|
||||
data: Object.values(byLabel).sort((a,b) => b.value - a.value),
|
||||
width:'100%', height:'100%',
|
||||
label:{show:true, fontSize:11, color:'#fff'},
|
||||
upperLabel:{show:true, height:22, fontSize:12, color:'#fff', fontWeight:'bold',
|
||||
backgroundColor:'transparent'},
|
||||
itemStyle:{borderColor:'#111827', borderWidth:2, gapWidth:2},
|
||||
levels:[
|
||||
{itemStyle:{borderColor:'#1f2937', borderWidth:3, gapWidth:3},
|
||||
upperLabel:{show:true}},
|
||||
{colorSaturation:[0.4,0.8],
|
||||
itemStyle:{borderColorSaturation:0.5, gapWidth:1, borderWidth:1}},
|
||||
],
|
||||
tooltip: ecTooltip({formatter:i=>`${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
|
||||
series:[{type:'treemap', data:Object.values(byLabel).sort((a,b)=>b.value-a.value),
|
||||
width:'100%',height:'100%', label:{show:true,fontSize:11,color:'#fff'},
|
||||
upperLabel:{show:true,height:20,fontSize:11,color:'#fff',fontWeight:'bold',backgroundColor:'transparent'},
|
||||
itemStyle:{borderColor:'#111827',borderWidth:2,gapWidth:1},
|
||||
levels:[{itemStyle:{borderColor:'#1f2937',borderWidth:3},upperLabel:{show:true}},{colorSaturation:[0.4,0.8]}],
|
||||
}]
|
||||
}));
|
||||
treemapChart.on('click', params => {
|
||||
if (params.data?.name && params.treePathInfo?.length > 2) window.location.href = '/detections?asn_org=' + encodeURIComponent(params.data.name);
|
||||
});
|
||||
treemapChart.on('click', p => { if(p.data?.name && p.treePathInfo?.length>2) window.location.href='/detections?asn_org='+encodeURIComponent(p.data.name); });
|
||||
}
|
||||
|
||||
// ── Country Sunburst ──
|
||||
const sunburstChart = initChart('chart-sunburst');
|
||||
if (sunburstChart && countries.length) {
|
||||
const byCountry = {};
|
||||
// Sunburst
|
||||
const sunChart = initChart('chart-sunburst');
|
||||
if (sunChart && countries.length) {
|
||||
const byC = {};
|
||||
countries.forEach(c => {
|
||||
if (!byCountry[c.country_code]) byCountry[c.country_code] = {name:c.country_code, children:[]};
|
||||
byCountry[c.country_code].children.push({
|
||||
name: c.asn_label || 'unknown',
|
||||
value: c.sessions || 0,
|
||||
itemStyle:{color: LABEL_COLORS[c.asn_label] || '#6b7280'},
|
||||
});
|
||||
if(!byC[c.country_code]) byC[c.country_code] = {name:c.country_code, children:[]};
|
||||
byC[c.country_code].children.push({name:c.asn_label||'unknown', value:c.sessions||0, itemStyle:{color:LABEL_COLORS[c.asn_label]||'#6b7280'}});
|
||||
});
|
||||
sunburstChart.setOption(ecBase({
|
||||
tooltip: ecTooltip({formatter: i => `${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
|
||||
series:[{
|
||||
type:'sunburst',
|
||||
data: Object.values(byCountry).sort((a,b) => {
|
||||
const va = a.children.reduce((s,c) => s+c.value,0);
|
||||
const vb = b.children.reduce((s,c) => s+c.value,0);
|
||||
return vb - va;
|
||||
}),
|
||||
radius:['15%','90%'],
|
||||
label:{color:'#e5e7eb', fontSize:11, rotate:'radial'},
|
||||
itemStyle:{borderColor:'#111827', borderWidth:1},
|
||||
levels:[
|
||||
{},
|
||||
{r0:'15%', r:'50%', label:{fontSize:13, fontWeight:'bold'},
|
||||
itemStyle:{borderWidth:2}},
|
||||
{r0:'50%', r:'90%', label:{fontSize:10}},
|
||||
],
|
||||
sunChart.setOption(ecBase({
|
||||
tooltip:ecTooltip({formatter:i=>`${i.name}: ${(i.value||0).toLocaleString()}`}),
|
||||
series:[{type:'sunburst', data:Object.values(byC).sort((a,b)=>{const va=a.children.reduce((s,c)=>s+c.value,0);const vb=b.children.reduce((s,c)=>s+c.value,0);return vb-va;}),
|
||||
radius:['15%','90%'], label:{color:'#e5e7eb',fontSize:10,rotate:'radial'},
|
||||
itemStyle:{borderColor:'#111827',borderWidth:1},
|
||||
levels:[{},{r0:'15%',r:'50%',label:{fontSize:12,fontWeight:'bold'}},{r0:'50%',r:'90%',label:{fontSize:9}}],
|
||||
}]
|
||||
}));
|
||||
sunburstChart.on('click', params => {
|
||||
if (params.data?.name && params.data.name.length <= 3) window.location.href = '/detections?country_code=' + encodeURIComponent(params.data.name);
|
||||
});
|
||||
}
|
||||
|
||||
// ── JA4 Fingerprint Table ──
|
||||
// JA4 Rotation table
|
||||
document.getElementById('rotation-body').innerHTML = rotData.map(r =>
|
||||
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
|
||||
<td>${fmtIP(r.src_ip)}</td>
|
||||
<td class="text-xs">${escapeHtml(r.host||'')}</td>
|
||||
<td class="text-center"><span class="badge badge-high">${r.distinct_ja4}</span></td>
|
||||
<td class="font-mono text-xs">${r.total_hits||0}</td>
|
||||
<td class="text-[11px] text-gray-400">${(r.window_start||'').substring(0,16)}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="5" class="text-center py-4 text-gray-500">Aucune rotation détectée</td></tr>';
|
||||
|
||||
// Brute-force table
|
||||
document.getElementById('brute-body').innerHTML = bfData.map(r =>
|
||||
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
|
||||
<td>${fmtIP(r.src_ip)}</td>
|
||||
<td class="text-xs">${escapeHtml(r.host||'')}</td>
|
||||
<td class="font-mono text-red-400">${r.post_count}</td>
|
||||
<td class="text-xs">${r.distinct_paths}</td>
|
||||
<td class="text-[11px] text-gray-400">${(r.first_seen||'').substring(0,16)}</td>
|
||||
<td class="text-[11px] text-gray-400">${(r.last_seen||'').substring(0,16)}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="6" class="text-center py-4 text-gray-500">Aucun brute-force détecté</td></tr>';
|
||||
|
||||
// Recurrence table
|
||||
document.getElementById('recurrence-body').innerHTML = recData.map(r =>
|
||||
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
|
||||
<td>${fmtIP(r.src_ip)}</td>
|
||||
<td class="text-center font-bold text-orange-400">${r.recurrence}</td>
|
||||
<td>${fmtScore(r.worst_score)}</td>
|
||||
<td>${threatBadge(r.worst_threat)}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="4" class="text-center py-4 text-gray-500">Aucune récurrence</td></tr>';
|
||||
|
||||
// JA4 table
|
||||
const botMap = {};
|
||||
botJa4.forEach(b => { botMap[b.ja4] = b.bot_name; });
|
||||
ja4Rows = ja4Stats.map(j => [
|
||||
j.ja4,
|
||||
j.sessions || 0,
|
||||
j.total_hits || 0,
|
||||
j.avg_velocity || 0,
|
||||
j.avg_fuzz || 0,
|
||||
j.avg_browser_score || 0,
|
||||
j.asn_label || 'unknown',
|
||||
botMap[j.ja4] || '',
|
||||
]);
|
||||
ja4Rows = ja4Stats.map(j => [j.ja4, j.sessions||0, j.total_hits||0, j.avg_velocity||0, j.avg_fuzz||0, j.avg_browser_score||0, j.asn_label||'unknown', botMap[j.ja4]||'']);
|
||||
renderJA4Table();
|
||||
|
||||
// ── ASN Detail Table ──
|
||||
document.getElementById('asn-body').innerHTML = asns
|
||||
.sort((a,b) => (b.sessions||0) - (a.sessions||0))
|
||||
.map(a => `<tr>
|
||||
<td class="text-xs">${fmtASN(a.asn_org)}</td>
|
||||
<td>${fmtLabel(a.asn_label)}</td>
|
||||
<td>${fmtCountry(a.country_code)}</td>
|
||||
<td>${(a.sessions||0).toLocaleString()}</td>
|
||||
<td>${(a.total_hits||0).toLocaleString()}</td>
|
||||
<td>${(a.avg_velocity||0).toFixed(3)}</td>
|
||||
<td>${(a.avg_fuzz||0).toFixed(3)}</td>
|
||||
</tr>`).join('');
|
||||
// ASN table
|
||||
document.getElementById('asn-body').innerHTML = asns.sort((a,b)=>(b.sessions||0)-(a.sessions||0)).map(a =>
|
||||
`<tr><td class="text-xs">${fmtASN(a.asn_org)}</td><td>${fmtLabel(a.asn_label)}</td><td>${fmtCountry(a.country_code)}</td>
|
||||
<td>${(a.sessions||0).toLocaleString()}</td><td>${(a.total_hits||0).toLocaleString()}</td>
|
||||
<td>${(a.avg_velocity||0).toFixed(3)}</td><td>${(a.avg_fuzz||0).toFixed(3)}</td></tr>`).join('');
|
||||
|
||||
// ── Bot Fingerprints Pie ──
|
||||
const botPieChart = initChart('chart-botpie');
|
||||
if (botPieChart && botJa4.length) {
|
||||
// Bot pie
|
||||
const bpChart = initChart('chart-botpie');
|
||||
if (bpChart && botJa4.length) {
|
||||
const byBot = {};
|
||||
botJa4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0) + (b.sessions||0); });
|
||||
botPieChart.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
|
||||
series:[{
|
||||
type:'pie', radius:['35%','75%'], center:['50%','55%'],
|
||||
label:{color:EC_TEXT, fontSize:11, formatter:'{b}\n{d}%'},
|
||||
data: Object.entries(byBot)
|
||||
.map(([name,value],i) => ({name, value, itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}}))
|
||||
.sort((a,b) => b.value - a.value),
|
||||
emphasis:{itemStyle:{shadowBlur:10, shadowColor:'rgba(0,0,0,0.5)'}},
|
||||
botJa4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0)+(b.sessions||0); });
|
||||
bpChart.setOption(ecBase({
|
||||
tooltip:ecTooltip({trigger:'item',formatter:'{b}: {c} ({d}%)'}),
|
||||
series:[{type:'pie',radius:['35%','75%'],center:['50%','55%'],
|
||||
label:{color:EC_TEXT,fontSize:10,formatter:'{b}\n{d}%'},
|
||||
data:Object.entries(byBot).map(([n,v],i)=>({name:n,value:v,itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}})).sort((a,b)=>b.value-a.value),
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
} catch(e) { console.error('Network load error:', e); }
|
||||
}
|
||||
|
||||
loadAll();
|
||||
setInterval(loadAll, 60000);
|
||||
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,87 +1,184 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Overview{% endblock %}
|
||||
{% block page_title %}
|
||||
Centre de commande
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">?</button><div class="doc-panel">
|
||||
<h4>Centre de commande SOC</h4>
|
||||
<p>Vue d'ensemble temps réel de la posture de sécurité. Les KPI montrent les dernières 24h. La timeline et les alertes se rafraîchissent toutes les 60s.</p>
|
||||
<p><strong>Workflow :</strong> Identifiez les pics → cliquez sur une alerte → investiguez l'IP → classifiez.</p>
|
||||
<p class="doc-source">Sources : ml_detected_anomalies, ml_all_scores, http_logs</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- KPI Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4" id="kpi-grid">
|
||||
<div class="space-y-4">
|
||||
<!-- ═══ KPI Row ═══ -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3" id="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-red-400"></span><span class="text-xs text-gray-500">Détections 24h</span></div>
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-red-500"></span><span class="text-[11px] text-gray-500">Détections 24h</span></div>
|
||||
<div class="text-2xl font-bold text-red-400" id="kpi-detections">—</div>
|
||||
<div class="text-[10px] text-gray-600 mt-1" id="kpi-det-trend"></div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-indigo-400"></span><span class="text-xs text-gray-500">Sessions scorées</span></div>
|
||||
<div class="text-2xl font-bold text-brand-500" id="kpi-scored">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-gray-400"></span><span class="text-xs text-gray-500">Trafic total 24h</span></div>
|
||||
<div class="text-2xl font-bold text-gray-200" id="kpi-traffic">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span><span class="text-xs text-gray-500">IPs uniques</span></div>
|
||||
<div class="text-2xl font-bold text-yellow-400" id="kpi-ips">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-orange-400"></span><span class="text-xs text-gray-500">Critical / High</span></div>
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-orange-500"></span><span class="text-[11px] text-gray-500">Critical + High</span></div>
|
||||
<div class="text-2xl font-bold text-orange-400" id="kpi-critical">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-green-400"></span><span class="text-xs text-gray-500">Modèles actifs</span></div>
|
||||
<div class="text-2xl font-bold text-green-400" id="kpi-models">—</div>
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-indigo-400"></span><span class="text-[11px] text-gray-500">Sessions ML</span></div>
|
||||
<div class="text-2xl font-bold text-brand-500" id="kpi-scored">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span><span class="text-[11px] text-gray-500">IPs uniques</span></div>
|
||||
<div class="text-2xl font-bold text-yellow-400" id="kpi-ips">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-green-400"></span><span class="text-[11px] text-gray-500">Navigateurs légit.</span></div>
|
||||
<div class="text-2xl font-bold text-green-400" id="kpi-browsers">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-blue-400"></span><span class="text-[11px] text-gray-500">Bots connus</span></div>
|
||||
<div class="text-2xl font-bold text-blue-400" id="kpi-knownbots">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1: Timeline + Threats -->
|
||||
<!-- ═══ Row 2: Timeline + Live alerts ═══ -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<!-- Timeline (2/3) -->
|
||||
<div class="xl:col-span-2 section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<svg class="w-4 h-4 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
Timeline détections (24h)
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Timeline des détections</h4>
|
||||
<p>Courbes empilées par niveau de menace. Un pic soudain indique une attaque en cours ou un nouveau pattern détecté.</p>
|
||||
<p><strong>Action :</strong> Cliquez sur un pic pour filtrer les détections de cette heure.</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies GROUP BY hour, threat_level</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body"><div id="chart-timeline" style="height:280px"></div></div>
|
||||
</div>
|
||||
<!-- Live alerts (1/3) -->
|
||||
<div class="section-card flex flex-col">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
|
||||
Alertes récentes
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Flux d'alertes temps réel</h4>
|
||||
<p>Dernières détections HIGH/CRITICAL/KNOWN_BOT. Cliquez sur une IP pour démarrer l'investigation.</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies ORDER BY detected_at DESC</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto" style="max-height:280px" id="alerts-feed">
|
||||
<div class="px-4 py-8 text-center text-gray-500 text-xs">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Row 3: Threat pie + Browsers + Top IPs ═══ -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Threat Levels
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Distribution des menaces</h4>
|
||||
<p>Répartition des sessions par niveau : CRITICAL (score >0.70), HIGH (>0.40), MEDIUM (>0.10), NORMAL, LEGITIMATE_BROWSER, KNOWN_BOT.</p>
|
||||
<p><strong>Action :</strong> Cliquez sur un segment pour filtrer les détections.</p>
|
||||
<p class="doc-source">Source : ml_all_scores.threat_level</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body"><div id="chart-threats" style="height:220px"></div></div>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Navigateurs
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Familles de navigateurs</h4>
|
||||
<p>Identification via dictionnaire JA4 → browser_family. Les navigateurs légitimes sont exemptés du scoring ML.</p>
|
||||
<p class="doc-source">Source : ml_all_scores.browser_family</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body"><div id="chart-browsers" style="height:220px"></div></div>
|
||||
</div>
|
||||
<div class="lg:col-span-2 section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Top 10 IPs suspectes
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>IPs les plus détectées</h4>
|
||||
<p>IPs avec le plus grand nombre de détections sur 24h. Le score indique le pire score observé.</p>
|
||||
<p><strong>Action :</strong> Cliquez sur une IP pour l'investiguer en profondeur.</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies GROUP BY src_ip</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>IP</th><th>Dét.</th><th>Score</th><th>Threat</th><th>ASN</th><th>Pays</th></tr></thead>
|
||||
<tbody id="top-ips-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Row 4: ASN + Geo + Campaigns ═══ -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2 bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Détections par heure (24h)</h3>
|
||||
<div id="chart-timeline" style="height:280px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Top ASN
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Systèmes autonomes</h4>
|
||||
<p>ASN les plus représentés. Couleur : <span class="text-green-400">ISP</span> (résidentiel), <span class="text-red-400">Datacenter</span>, <span class="text-orange-400">Hosting</span>.</p>
|
||||
<p><strong>Action :</strong> Cliquez pour voir les détails réseau.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h.asn_org</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body"><div id="chart-asn" style="height:260px"></div></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Threat levels</h3>
|
||||
<div id="chart-threats" style="height:280px"></div>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Géographie
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Répartition géographique</h4>
|
||||
<p>Treemap par pays et type d'ASN. La taille indique le nombre de sessions.</p>
|
||||
<p class="doc-source">Source : view_ai_features_1h.country_code</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body"><div id="chart-geo" style="height:260px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2: ASN Treemap + Country -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Répartition par ASN</h3>
|
||||
<div id="chart-asn" style="height:300px"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Répartition géographique</h3>
|
||||
<div id="chart-geo" style="height:300px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 3: JA4 Diversity + Bot names -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Empreintes JA4 (top 15)</h3>
|
||||
<div id="chart-ja4" style="height:300px"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Bots identifiés</h3>
|
||||
<div id="chart-bots" style="height:300px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top IPs table -->
|
||||
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Top 10 IPs détectées (24h)</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>IP</th><th>Détections</th><th>Pire score</th><th>Threat Level</th><th>ASN</th><th>Pays</th></tr></thead>
|
||||
<tbody id="top-ips-body"></tbody>
|
||||
</table>
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Campagnes bots
|
||||
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Campagnes détectées (HDBSCAN)</h4>
|
||||
<p>Groupes d'IPs présentant des patterns comportementaux similaires, identifiés par clustering HDBSCAN sur les features ML.</p>
|
||||
<p><strong>Action :</strong> Une campagne multi-IP indique une attaque coordonnée (botnet, scraping distribué).</p>
|
||||
<p class="doc-source">Source : ml_detected_anomalies.campaign_id</p>
|
||||
</div></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body p-0">
|
||||
<div class="overflow-y-auto" style="max-height:260px" id="campaigns-list">
|
||||
<div class="px-4 py-8 text-center text-gray-500 text-xs">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',LOW:'#22c55e',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'};
|
||||
const LABEL_COLORS = {human:'#22c55e',datacenter:'#ef4444',hosting:'#f97316',unknown:'#6b7280'};
|
||||
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',LOW:'#22c55e',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626',LEGITIMATE_BROWSER:'#22c55e'};
|
||||
const LABEL_COLORS = {isp:'#22c55e',datacenter:'#ef4444',hosting:'#f97316',cdn:'#06b6d4',unknown:'#6b7280'};
|
||||
|
||||
let charts = {};
|
||||
function initChart(id) {
|
||||
@ -94,73 +191,125 @@ function initChart(id) {
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const [ov, geo, fp] = await Promise.all([
|
||||
const [ov, geo, fp, alerts, tl, campaigns] = await Promise.all([
|
||||
fetch('/api/overview').then(r=>r.json()),
|
||||
fetch('/api/geo').then(r=>r.json()),
|
||||
fetch('/api/fingerprints').then(r=>r.json()),
|
||||
fetch('/api/alerts?limit=20').then(r=>r.json()),
|
||||
fetch('/api/timeline-detail').then(r=>r.json()),
|
||||
fetch('/api/campaigns').then(r=>r.json()),
|
||||
]);
|
||||
|
||||
// KPIs
|
||||
document.getElementById('kpi-detections').textContent = (ov.detections_24h??0).toLocaleString();
|
||||
document.getElementById('kpi-scored').textContent = (ov.scored_24h??0).toLocaleString();
|
||||
document.getElementById('kpi-traffic').textContent = (ov.traffic_24h??0).toLocaleString();
|
||||
document.getElementById('kpi-ips').textContent = (ov.unique_ips??0).toLocaleString();
|
||||
document.getElementById('kpi-critical').textContent = ((ov.critical_count??0)+(ov.high_count??0)).toLocaleString();
|
||||
document.getElementById('kpi-models').textContent = ov.models?.length ?? 0;
|
||||
// ── KPIs ──
|
||||
const threatMap = {};
|
||||
(ov.threat_distribution||[]).forEach(t => { threatMap[t.threat_level] = t.cnt; });
|
||||
|
||||
// Timeline area chart
|
||||
if (ov.timeline?.length) {
|
||||
document.getElementById('kpi-detections').textContent = fmtNum(ov.detections_24h);
|
||||
document.getElementById('kpi-critical').textContent = fmtNum((threatMap.CRITICAL||0)+(threatMap.HIGH||0));
|
||||
document.getElementById('kpi-scored').textContent = fmtNum(ov.scored_24h);
|
||||
document.getElementById('kpi-ips').textContent = fmtNum(ov.unique_ips);
|
||||
document.getElementById('kpi-browsers').textContent = fmtNum(ov.legitimate_browsers);
|
||||
document.getElementById('kpi-knownbots').textContent = fmtNum(threatMap.KNOWN_BOT||0);
|
||||
|
||||
// ── Stacked timeline ──
|
||||
const timeline = tl.timeline || [];
|
||||
if (timeline.length) {
|
||||
const ch = initChart('chart-timeline');
|
||||
const levels = ['CRITICAL','HIGH','MEDIUM','KNOWN_BOT'];
|
||||
const hours = timeline.map(t => t.hour?.substring(11,16)||'');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'axis'}),
|
||||
grid: {left:50,right:20,top:20,bottom:30},
|
||||
xAxis: {type:'category', data: ov.timeline.map(t=>t.hour?.substring(11,16)||''), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT}},
|
||||
legend: {data:levels, textStyle:{color:EC_TEXT,fontSize:10}, top:0, right:0, itemWidth:12, itemHeight:8},
|
||||
grid: ecGrid({top:30}),
|
||||
xAxis: {type:'category', data:hours, axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
||||
series: [{
|
||||
type:'line', data: ov.timeline.map(t=>t.cnt), smooth:true,
|
||||
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(99,102,241,0.4)'},{offset:1,color:'rgba(99,102,241,0.02)'}])},
|
||||
lineStyle:{color:'#6366f1',width:2}, itemStyle:{color:'#6366f1'}, symbol:'circle', symbolSize:6,
|
||||
}]
|
||||
series: levels.map(lvl => ({
|
||||
name:lvl, type:'bar', stack:'total',
|
||||
data: timeline.map(t => t[lvl]||0),
|
||||
itemStyle:{color:THREAT_COLORS[lvl]},
|
||||
emphasis:{focus:'series'},
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
// Threats pie
|
||||
// ── Live alerts feed ──
|
||||
const feed = document.getElementById('alerts-feed');
|
||||
const alertRows = alerts.alerts || [];
|
||||
if (alertRows.length) {
|
||||
feed.innerHTML = alertRows.map(a => {
|
||||
const ip = String(a.src_ip||'').replace('::ffff:','');
|
||||
const color = a.threat_level === 'CRITICAL' ? 'border-l-red-500' :
|
||||
a.threat_level === 'HIGH' ? 'border-l-orange-500' :
|
||||
a.threat_level === 'KNOWN_BOT' ? 'border-l-blue-500' : 'border-l-gray-700';
|
||||
return `<a href="/ip/${encodeURIComponent(ip)}" class="block px-4 py-2.5 border-b border-l-2 ${color} border-gray-800/50 hover:bg-gray-800/40 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
${threatBadge(a.threat_level)}
|
||||
<span class="font-mono text-xs text-brand-400">${escapeHtml(ip)}</span>
|
||||
<span class="text-[10px] text-gray-500 ml-auto">${fmtAgo(a.detected_at)}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400 mt-0.5 truncate">
|
||||
${a.bot_name ? '<span class="text-cyan-400">'+escapeHtml(a.bot_name)+'</span> · ' : ''}
|
||||
${escapeHtml(a.host||'')} · ${a.hits||0} hits · score ${parseFloat(a.anomaly_score||0).toFixed(3)}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
} else {
|
||||
feed.innerHTML = '<div class="px-4 py-8 text-center text-gray-500 text-xs">Aucune alerte récente</div>';
|
||||
}
|
||||
|
||||
// ── Threats donut ──
|
||||
if (ov.threat_distribution?.length) {
|
||||
const ch = initChart('chart-threats');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
|
||||
series: [{
|
||||
type:'pie', radius:['45%','75%'], center:['50%','55%'],
|
||||
label:{color:EC_TEXT, fontSize:11},
|
||||
data: ov.threat_distribution.map(t=>({name:t.threat_level, value:t.cnt, itemStyle:{color:THREAT_COLORS[t.threat_level]||'#6b7280'}})),
|
||||
emphasis:{itemStyle:{shadowBlur:10,shadowColor:'rgba(0,0,0,0.5)'}},
|
||||
type:'pie', radius:['50%','80%'], center:['50%','55%'],
|
||||
label:{show:false},
|
||||
data: ov.threat_distribution.map(t=>({
|
||||
name:t.threat_level, value:t.cnt,
|
||||
itemStyle:{color:THREAT_COLORS[t.threat_level]||'#6b7280'}
|
||||
})),
|
||||
emphasis:{label:{show:true,color:'#fff',fontSize:12,fontWeight:'bold'}},
|
||||
}]
|
||||
}));
|
||||
ch.on('click', params => {
|
||||
if (params.name) window.location.href = '/detections?threat_level=' + encodeURIComponent(params.name);
|
||||
});
|
||||
ch.on('click', p => { if(p.name) window.location.href='/detections?threat_level='+encodeURIComponent(p.name); });
|
||||
}
|
||||
|
||||
// ASN horizontal bar
|
||||
// ── Browser donut ──
|
||||
if (ov.browser_stats?.length) {
|
||||
const ch = initChart('chart-browsers');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
|
||||
series: [{
|
||||
type:'pie', radius:['50%','80%'], center:['50%','55%'],
|
||||
label:{show:false},
|
||||
data: ov.browser_stats.map((b,i)=>({
|
||||
name:b.browser_family, value:b.cnt,
|
||||
itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}
|
||||
})),
|
||||
emphasis:{label:{show:true,color:'#fff',fontSize:11}},
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
// ── ASN horizontal bar ──
|
||||
if (geo.asns?.length) {
|
||||
const top = geo.asns.slice(0,12);
|
||||
const top = geo.asns.slice(0,10);
|
||||
const ch = initChart('chart-asn');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'axis',axisPointer:{type:'shadow'}}),
|
||||
grid: {left:160,right:30,top:10,bottom:30},
|
||||
yAxis: {type:'category', data: top.map(a=>a.asn_org).reverse(), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:11,width:140,overflow:'truncate'}},
|
||||
xAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
||||
grid: {left:130,right:30,top:5,bottom:5},
|
||||
yAxis: {type:'category', data:top.map(a=>a.asn_org).reverse(), axisLine:{show:false}, axisLabel:{color:EC_TEXT,fontSize:10,width:120,overflow:'truncate'}},
|
||||
xAxis: {type:'value', show:false},
|
||||
series: [{
|
||||
type:'bar', data: top.map(a=>({value:a.sessions,itemStyle:{color:LABEL_COLORS[a.asn_label]||'#6b7280'}})).reverse(),
|
||||
barWidth:'60%', label:{show:true,position:'right',color:EC_TEXT,fontSize:11},
|
||||
type:'bar', data:top.map(a=>({value:a.sessions,itemStyle:{color:LABEL_COLORS[a.asn_label]||'#6b7280'}})).reverse(),
|
||||
barWidth:'60%', label:{show:true,position:'right',color:EC_TEXT,fontSize:10},
|
||||
}]
|
||||
}));
|
||||
ch.on('click', params => {
|
||||
if (params.name) window.location.href = '/detections?asn_org=' + encodeURIComponent(params.name);
|
||||
});
|
||||
ch.on('click', p => { if(p.name) window.location.href='/network?asn_org='+encodeURIComponent(p.name); });
|
||||
}
|
||||
|
||||
// Country treemap
|
||||
// ── Country treemap ──
|
||||
if (geo.countries?.length) {
|
||||
const byCountry = {};
|
||||
geo.countries.forEach(c => {
|
||||
@ -170,78 +319,45 @@ async function loadOverview() {
|
||||
});
|
||||
const ch = initChart('chart-geo');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({formatter:i => `${i.name}: ${i.value} sessions`}),
|
||||
series: [{
|
||||
type:'treemap', data: Object.values(byCountry).sort((a,b)=>b.value-a.value).slice(0,20),
|
||||
width:'100%', height:'100%',
|
||||
label:{show:true,fontSize:13,color:'#fff',fontWeight:'bold'},
|
||||
upperLabel:{show:false},
|
||||
itemStyle:{borderColor:'#111827',borderWidth:2,gapWidth:2},
|
||||
levels:[
|
||||
{itemStyle:{borderColor:'#1f2937',borderWidth:3,gapWidth:3},upperLabel:{show:false}},
|
||||
{colorSaturation:[0.3,0.7],itemStyle:{borderColorSaturation:0.6,gapWidth:1,borderWidth:1}},
|
||||
],
|
||||
colorMappingBy:'value',
|
||||
color: EC_COLORS,
|
||||
tooltip: ecTooltip({formatter:i=>`${i.name}: ${(i.value||0).toLocaleString()} sessions`}),
|
||||
series:[{type:'treemap', data:Object.values(byCountry).sort((a,b)=>b.value-a.value).slice(0,20),
|
||||
width:'100%',height:'100%',
|
||||
label:{show:true,fontSize:12,color:'#fff',fontWeight:'bold'},
|
||||
itemStyle:{borderColor:'#111827',borderWidth:2,gapWidth:1},
|
||||
levels:[{itemStyle:{borderColor:'#1f2937',borderWidth:3}},{colorSaturation:[0.3,0.7]}],
|
||||
color:EC_COLORS,
|
||||
}]
|
||||
}));
|
||||
ch.on('click', params => {
|
||||
if (params.data?.name && params.data.name.length <= 3) window.location.href = '/detections?country_code=' + encodeURIComponent(params.data.name);
|
||||
});
|
||||
ch.on('click', p => { if(p.data?.name?.length<=3) window.location.href='/detections?country_code='+encodeURIComponent(p.data.name); });
|
||||
}
|
||||
|
||||
// JA4 bar chart
|
||||
if (fp.ja4_stats?.length) {
|
||||
const top15 = fp.ja4_stats.slice(0,15);
|
||||
const ch = initChart('chart-ja4');
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'axis',axisPointer:{type:'shadow'}}),
|
||||
grid: {left:50,right:20,top:10,bottom:80},
|
||||
xAxis: {type:'category', data:top15.map(j=>j.ja4.substring(0,16)+'…'), axisLabel:{color:EC_TEXT,rotate:45,fontSize:10}, axisLine:{lineStyle:{color:EC_GRID}}},
|
||||
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
||||
series: [{
|
||||
type:'bar', data:top15.map(j=>({value:j.sessions,itemStyle:{color:LABEL_COLORS[j.asn_label]||EC_COLORS[0]}})),
|
||||
barWidth:'65%',
|
||||
}]
|
||||
}));
|
||||
ch.on('click', params => {
|
||||
if (params.dataIndex !== undefined) {
|
||||
const ja4 = fp.ja4_stats[params.dataIndex]?.ja4;
|
||||
if (ja4) window.location.href = '/detections?ja4=' + encodeURIComponent(ja4);
|
||||
}
|
||||
});
|
||||
}
|
||||
// ── Top IPs table ──
|
||||
document.getElementById('top-ips-body').innerHTML = (ov.top_ips||[]).map(ip => `<tr onclick="window.location='/ip/${encodeURIComponent(String(ip.src_ip).replace('::ffff:',''))}'">
|
||||
<td>${fmtIP(ip.src_ip)}</td><td class="font-mono">${ip.cnt}</td><td>${fmtScore(ip.worst_score)}</td>
|
||||
<td>${threatBadge(ip.threat_level)}</td><td class="text-xs">${fmtASN(ip.asn_org)}</td><td>${fmtCountry(ip.country_code)}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="6" class="text-center py-4 text-gray-500">Aucune donnée</td></tr>';
|
||||
|
||||
// Bots pie
|
||||
if (fp.bot_ja4?.length) {
|
||||
const ch = initChart('chart-bots');
|
||||
const byBot = {};
|
||||
fp.bot_ja4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0) + b.sessions; });
|
||||
ch.setOption(ecBase({
|
||||
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
|
||||
series: [{
|
||||
type:'pie', radius:['35%','70%'], center:['50%','55%'],
|
||||
label:{color:EC_TEXT,fontSize:11,formatter:'{b}\n{d}%'},
|
||||
data: Object.entries(byBot).map(([k,v],i)=>({name:k,value:v,itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}}))
|
||||
.sort((a,b)=>b.value-a.value),
|
||||
emphasis:{itemStyle:{shadowBlur:10}},
|
||||
}]
|
||||
}));
|
||||
ch.on('click', params => {
|
||||
if (params.name) window.location.href = '/detections?bot_name=' + encodeURIComponent(params.name);
|
||||
});
|
||||
// ── Campaigns list ──
|
||||
const campEl = document.getElementById('campaigns-list');
|
||||
const camps = campaigns.campaigns || [];
|
||||
if (camps.length) {
|
||||
campEl.innerHTML = camps.map(c => `<div class="px-4 py-2.5 border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono text-purple-400">Camp. #${escapeHtml(String(c.campaign_id).substring(0,8))}</span>
|
||||
<span class="badge badge-high">${c.members} membres</span>
|
||||
<span class="text-[10px] text-gray-500 ml-auto">${c.unique_ips} IPs</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400">
|
||||
Score max: ${fmtScore(c.max_score)} · ASN: ${(c.asn_list||[]).map(a=>escapeHtml(a)).join(', ')||'—'}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
campEl.innerHTML = '<div class="px-4 py-8 text-center text-gray-500 text-xs">Aucune campagne active</div>';
|
||||
}
|
||||
|
||||
// Top IPs table
|
||||
document.getElementById('top-ips-body').innerHTML = (ov.top_ips||[]).map(ip => `<tr>
|
||||
<td>${fmtIP(ip.src_ip)}</td><td>${ip.cnt}</td><td>${fmtScore(ip.worst_score)}</td>
|
||||
<td>${fmtThreatLink(ip.threat_level)}</td><td class="text-xs">${fmtASN(ip.asn_org)}</td><td>${fmtCountry(ip.country_code)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
} catch(e) { console.error('Overview load error:', e); }
|
||||
}
|
||||
loadOverview();
|
||||
setInterval(loadOverview, 60000);
|
||||
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Scores ML{% endblock %}
|
||||
{% block page_title %}
|
||||
Scores ML
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Toutes les classifications ML</h4>
|
||||
<p>Chaque session analysée reçoit un score composite (EIF + AE + XGBoost). Cette vue montre TOUTES les sessions, pas seulement les anomalies.</p>
|
||||
<p><strong>AE Error :</strong> Erreur de reconstruction autoencoder (élevé = inhabituel).</p>
|
||||
<p><strong>XGB Prob :</strong> Probabilité supervisée (entraîné sur labels SOC).</p>
|
||||
<p class="doc-source">Source : ml_all_scores (3 derniers jours)</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-4">
|
||||
<!-- Score distribution charts -->
|
||||
@ -14,7 +24,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-white">Toutes les classifications ML</h2>
|
||||
<div class="flex gap-1.5" id="threat-filters">
|
||||
<button class="filter-btn active" data-filter="">Tous</button>
|
||||
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JA4 SOC — Trafic HTTP{% endblock %}
|
||||
{% block page_title %}
|
||||
Trafic HTTP
|
||||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
||||
<h4>Logs HTTP bruts</h4>
|
||||
<p>Toutes les requêtes HTTP capturées (24h). Filtrez par méthode, host ou status pour identifier les patterns suspects.</p>
|
||||
<p><strong>Workflow :</strong> Filtrez POST → cherchez du brute-force → cliquez sur l'IP → investiguez.</p>
|
||||
<p class="doc-source">Source : http_logs (24h)</p>
|
||||
</div></span>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="space-y-4">
|
||||
<!-- Traffic summary charts -->
|
||||
@ -18,7 +27,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-white">Logs HTTP (24h)</h2>
|
||||
<select id="method-filter" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm text-gray-300">
|
||||
<option value="">Toutes méthodes</option>
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>HEAD</option><option>OPTIONS</option>
|
||||
|
||||
Reference in New Issue
Block a user