feat(dashboard): add JA4 fingerprint and cluster investigation pages
- /ja4/{fingerprint} page: 8 KPIs, timeline, threat pie, IP scores
table, ASN/geo charts, HTTP logs, AI features — full JA4 investigation
- /cluster/{cid} page: 8 KPIs, timeline, threat/JA4/ASN/host charts,
member table with bulk classify — full campaign investigation
- /api/ja4/{fingerprint} and /api/cluster/{cid} API endpoints
- fmtJA4 links now navigate to /ja4/ investigation page
- campaigns.html: 'Ouvrir' button links to /cluster/{cid} full page
- Fix: double-brace {{param}} in non-f-string queries → single {param}
(was causing HTTP 500 on all parameterized ClickHouse queries)
- 50 routes total, all tests pass, 0 JS console errors
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -1027,7 +1027,7 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
|
|||||||
f"asn_org, asn_number, country_code, "
|
f"asn_org, asn_number, country_code, "
|
||||||
f"browser_family, bot_name, detected_at, reason "
|
f"browser_family, bot_name, detected_at, reason "
|
||||||
f"FROM {_DB}.ml_detected_anomalies "
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
"WHERE campaign_id = {{cid:Int32}} "
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
"AND detected_at >= now() - INTERVAL 7 DAY "
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
"ORDER BY anomaly_score ASC LIMIT 200",
|
"ORDER BY anomaly_score ASC LIMIT 200",
|
||||||
{"cid": cid},
|
{"cid": cid},
|
||||||
@ -1048,7 +1048,7 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
|
|||||||
f"groupUniqArray(10)(host) AS host_list, "
|
f"groupUniqArray(10)(host) AS host_list, "
|
||||||
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen "
|
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen "
|
||||||
f"FROM {_DB}.ml_detected_anomalies "
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
"WHERE campaign_id = {{cid:Int32}} "
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
"AND detected_at >= now() - INTERVAL 7 DAY",
|
"AND detected_at >= now() - INTERVAL 7 DAY",
|
||||||
{"cid": cid},
|
{"cid": cid},
|
||||||
)
|
)
|
||||||
@ -1057,7 +1057,7 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
|
|||||||
f"SELECT toStartOfHour(detected_at) AS hour, "
|
f"SELECT toStartOfHour(detected_at) AS hour, "
|
||||||
f"count() AS detections, uniqExact(src_ip) AS active_ips "
|
f"count() AS detections, uniqExact(src_ip) AS active_ips "
|
||||||
f"FROM {_DB}.ml_detected_anomalies "
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
"WHERE campaign_id = {{cid:Int32}} "
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
"AND detected_at >= now() - INTERVAL 7 DAY "
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
"GROUP BY hour ORDER BY hour",
|
"GROUP BY hour ORDER BY hour",
|
||||||
{"cid": cid},
|
{"cid": cid},
|
||||||
@ -1226,3 +1226,247 @@ async def ua_rotation() -> dict[str, Any]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("ua-rotation query failed")
|
logger.exception("ua-rotation query failed")
|
||||||
return {"data": []}
|
return {"data": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/ja4/{fingerprint} — JA4 fingerprint investigation detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/ja4/{fingerprint:path}")
|
||||||
|
async def ja4_detail(fingerprint: str) -> dict[str, Any]:
|
||||||
|
"""Investigation complète d'une empreinte JA4 : IPs, scores, comportement."""
|
||||||
|
params = {"ja4": fingerprint}
|
||||||
|
try:
|
||||||
|
# IPs utilisant cette empreinte (détections)
|
||||||
|
detections = query(
|
||||||
|
f"SELECT toString(src_ip) AS src_ip, anomaly_score, "
|
||||||
|
f"raw_anomaly_score, threat_level, hits, hit_velocity, "
|
||||||
|
f"host, asn_org, country_code, browser_family, bot_name, "
|
||||||
|
f"detected_at, campaign_id "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"ORDER BY detected_at DESC LIMIT 500",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scores ML pour cette JA4
|
||||||
|
all_scores = query(
|
||||||
|
f"SELECT toString(src_ip) AS src_ip, anomaly_score, "
|
||||||
|
f"raw_anomaly_score, ae_recon_error, xgb_prob, "
|
||||||
|
f"threat_level, model_name, host, hits, "
|
||||||
|
f"asn_org, country_code, browser_family, detected_at "
|
||||||
|
f"FROM {_DB}.ml_all_scores "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 3 DAY "
|
||||||
|
"ORDER BY detected_at DESC LIMIT 500",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Profil agrégé
|
||||||
|
profile = query(
|
||||||
|
f"SELECT "
|
||||||
|
f"count() AS total_sessions, "
|
||||||
|
f"uniqExact(src_ip) AS unique_ips, "
|
||||||
|
f"uniqExact(host) AS unique_hosts, "
|
||||||
|
f"uniqExact(asn_org) AS unique_asns, "
|
||||||
|
f"avg(anomaly_score) AS avg_score, "
|
||||||
|
f"max(anomaly_score) AS max_score, "
|
||||||
|
f"avg(hits) AS avg_hits, "
|
||||||
|
f"avg(hit_velocity) AS avg_velocity, "
|
||||||
|
f"sum(hits) AS total_hits, "
|
||||||
|
f"groupUniqArray(20)(toString(src_ip)) AS ip_sample, "
|
||||||
|
f"groupUniqArray(10)(host) AS host_list, "
|
||||||
|
f"groupUniqArray(10)(asn_org) AS asn_list, "
|
||||||
|
f"groupUniqArray(10)(country_code) AS country_list, "
|
||||||
|
f"groupUniqArray(5)(browser_family) AS browser_list, "
|
||||||
|
f"groupUniqArray(5)(bot_name) AS bot_names, "
|
||||||
|
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen, "
|
||||||
|
f"countIf(threat_level IN ('HIGH','CRITICAL')) AS threat_count, "
|
||||||
|
f"countIf(threat_level = 'KNOWN_BOT') AS known_bot_count, "
|
||||||
|
f"countIf(browser_family != '') AS browser_count "
|
||||||
|
f"FROM {_DB}.ml_all_scores "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeline horaire
|
||||||
|
timeline = query(
|
||||||
|
f"SELECT toStartOfHour(detected_at) AS hour, "
|
||||||
|
f"count() AS sessions, uniqExact(src_ip) AS active_ips, "
|
||||||
|
f"avg(anomaly_score) AS avg_score "
|
||||||
|
f"FROM {_DB}.ml_all_scores "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 3 DAY "
|
||||||
|
"GROUP BY hour ORDER BY hour",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Threat breakdown
|
||||||
|
threats = query(
|
||||||
|
f"SELECT threat_level, count() AS cnt "
|
||||||
|
f"FROM {_DB}.ml_all_scores "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY threat_level ORDER BY cnt DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trafic HTTP brut
|
||||||
|
http_logs = query(
|
||||||
|
f"SELECT time, toString(src_ip) AS src_ip, method, host, path, "
|
||||||
|
f"http_version, header_user_agent "
|
||||||
|
f"FROM {_DB_LOGS}.http_logs "
|
||||||
|
"WHERE ja4 = {ja4:String} "
|
||||||
|
"AND time >= now() - INTERVAL 1 DAY "
|
||||||
|
"ORDER BY time DESC LIMIT 200",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI features pour cette JA4
|
||||||
|
ai_features: list[dict] = []
|
||||||
|
try:
|
||||||
|
ai_features = query(
|
||||||
|
f"SELECT * FROM {_DB}.view_ai_features_1h "
|
||||||
|
"WHERE ja4 = {ja4:String} LIMIT 20",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("view_ai_features_1h unavailable for ja4=%s", fingerprint)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ja4": fingerprint,
|
||||||
|
"profile": profile[0] if profile else {},
|
||||||
|
"detections": detections,
|
||||||
|
"scores": all_scores,
|
||||||
|
"timeline": timeline,
|
||||||
|
"threats": threats,
|
||||||
|
"http_logs": http_logs,
|
||||||
|
"ai_features": ai_features,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ja4 detail query failed for %s", fingerprint)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/cluster/{cid} — Enhanced cluster investigation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/cluster/{cid}")
|
||||||
|
async def cluster_detail(cid: int) -> dict[str, Any]:
|
||||||
|
"""Investigation complète d'un cluster : profil, membres, graphe, timeline."""
|
||||||
|
params = {"cid": cid}
|
||||||
|
try:
|
||||||
|
# Profil agrégé enrichi
|
||||||
|
profile = query(
|
||||||
|
f"SELECT "
|
||||||
|
f"count() AS total_members, "
|
||||||
|
f"uniqExact(src_ip) AS unique_ips, "
|
||||||
|
f"uniqExact(ja4) AS unique_ja4, "
|
||||||
|
f"uniqExact(host) AS unique_hosts, "
|
||||||
|
f"uniqExact(asn_org) AS unique_asns, "
|
||||||
|
f"avg(anomaly_score) AS avg_score, max(anomaly_score) AS max_score, "
|
||||||
|
f"min(anomaly_score) AS min_score, "
|
||||||
|
f"avg(hits) AS avg_hits, sum(hits) AS total_hits, "
|
||||||
|
f"avg(hit_velocity) AS avg_velocity, "
|
||||||
|
f"avg(fuzzing_index) AS avg_fuzzing, "
|
||||||
|
f"avg(post_ratio) AS avg_post_ratio, "
|
||||||
|
f"groupUniqArray(30)(toString(src_ip)) AS ip_list, "
|
||||||
|
f"groupUniqArray(20)(ja4) AS ja4_list, "
|
||||||
|
f"groupUniqArray(10)(host) AS host_list, "
|
||||||
|
f"groupUniqArray(10)(asn_org) AS asn_list, "
|
||||||
|
f"groupUniqArray(10)(country_code) AS country_list, "
|
||||||
|
f"groupUniqArray(5)(browser_family) AS browser_list, "
|
||||||
|
f"groupUniqArray(5)(bot_name) AS bot_names, "
|
||||||
|
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen, "
|
||||||
|
f"countIf(threat_level IN ('HIGH','CRITICAL')) AS threat_count, "
|
||||||
|
f"countIf(threat_level = 'KNOWN_BOT') AS known_bot_count "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Membres détaillés
|
||||||
|
members = query(
|
||||||
|
f"SELECT toString(src_ip) AS src_ip, ja4, host, "
|
||||||
|
f"anomaly_score, raw_anomaly_score, threat_level, "
|
||||||
|
f"hits, hit_velocity, fuzzing_index, post_ratio, "
|
||||||
|
f"asn_org, asn_number, country_code, "
|
||||||
|
f"browser_family, bot_name, detected_at, reason "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"ORDER BY anomaly_score ASC LIMIT 500",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeline horaire
|
||||||
|
timeline = query(
|
||||||
|
f"SELECT toStartOfHour(detected_at) AS hour, "
|
||||||
|
f"count() AS detections, uniqExact(src_ip) AS active_ips, "
|
||||||
|
f"avg(anomaly_score) AS avg_score "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY hour ORDER BY hour",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Répartition par JA4 (signature convergence)
|
||||||
|
ja4_breakdown = query(
|
||||||
|
f"SELECT ja4, count() AS sessions, "
|
||||||
|
f"uniqExact(src_ip) AS unique_ips, "
|
||||||
|
f"avg(anomaly_score) AS avg_score "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY ja4 ORDER BY sessions DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Répartition par ASN (infrastructure)
|
||||||
|
asn_breakdown = query(
|
||||||
|
f"SELECT asn_org, count() AS sessions, "
|
||||||
|
f"uniqExact(src_ip) AS unique_ips "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY asn_org ORDER BY sessions DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Répartition par host ciblé
|
||||||
|
host_breakdown = query(
|
||||||
|
f"SELECT host, count() AS sessions, "
|
||||||
|
f"avg(anomaly_score) AS avg_score "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY host ORDER BY sessions DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Threat breakdown
|
||||||
|
threats = query(
|
||||||
|
f"SELECT threat_level, count() AS cnt "
|
||||||
|
f"FROM {_DB}.ml_detected_anomalies "
|
||||||
|
"WHERE campaign_id = {cid:Int32} "
|
||||||
|
"AND detected_at >= now() - INTERVAL 7 DAY "
|
||||||
|
"GROUP BY threat_level ORDER BY cnt DESC",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"campaign_id": cid,
|
||||||
|
"profile": profile[0] if profile else {},
|
||||||
|
"members": members,
|
||||||
|
"timeline": timeline,
|
||||||
|
"ja4_breakdown": ja4_breakdown,
|
||||||
|
"asn_breakdown": asn_breakdown,
|
||||||
|
"host_breakdown": host_breakdown,
|
||||||
|
"threats": threats,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("cluster detail query failed for %s", cid)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|||||||
@ -61,3 +61,13 @@ async def network(request: Request):
|
|||||||
@router.get("/campaigns")
|
@router.get("/campaigns")
|
||||||
async def campaigns_page(request: Request):
|
async def campaigns_page(request: Request):
|
||||||
return templates.TemplateResponse("campaigns.html", _ctx(request, "campaigns"))
|
return templates.TemplateResponse("campaigns.html", _ctx(request, "campaigns"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ja4/{fingerprint:path}")
|
||||||
|
async def ja4_detail_page(request: Request, fingerprint: str):
|
||||||
|
return templates.TemplateResponse("ja4_detail.html", _ctx(request, "ja4_detail", ja4=fingerprint))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cluster/{cid}")
|
||||||
|
async def cluster_detail_page(request: Request, cid: int):
|
||||||
|
return templates.TemplateResponse("cluster_detail.html", _ctx(request, "cluster_detail", cid=cid))
|
||||||
|
|||||||
@ -291,11 +291,11 @@
|
|||||||
}
|
}
|
||||||
function fmtJA4(ja4) {
|
function fmtJA4(ja4) {
|
||||||
if (!ja4) return '';
|
if (!ja4) return '';
|
||||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,22))}…</a>`;
|
return `<a href="/ja4/${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,22))}…</a>`;
|
||||||
}
|
}
|
||||||
function fmtJA4Full(ja4) {
|
function fmtJA4Full(ja4) {
|
||||||
if (!ja4) return '';
|
if (!ja4) return '';
|
||||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
|
return `<a href="/ja4/${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
|
||||||
}
|
}
|
||||||
function fmtBotName(name) {
|
function fmtBotName(name) {
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
|
|||||||
@ -138,6 +138,7 @@
|
|||||||
Détail campagne <span class="font-mono text-purple-300" id="detail-cid"></span>
|
Détail campagne <span class="font-mono text-purple-300" id="detail-cid"></span>
|
||||||
</span>
|
</span>
|
||||||
<button onclick="closeDetail()" class="text-gray-400 hover:text-white transition-colors text-sm">✕ Fermer</button>
|
<button onclick="closeDetail()" class="text-gray-400 hover:text-white transition-colors text-sm">✕ Fermer</button>
|
||||||
|
<a id="detail-link" href="/cluster/0" class="px-3 py-1 bg-brand-600 text-white rounded text-xs font-medium hover:bg-brand-500 ml-2">Ouvrir ↗</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-body p-0">
|
<div class="section-body p-0">
|
||||||
<!-- Detail KPIs -->
|
<!-- Detail KPIs -->
|
||||||
@ -507,6 +508,7 @@ async function selectCampaign(cid) {
|
|||||||
const panel = document.getElementById('detail-panel');
|
const panel = document.getElementById('detail-panel');
|
||||||
panel.classList.add('open');
|
panel.classList.add('open');
|
||||||
document.getElementById('detail-cid').textContent = `#${cid}`;
|
document.getElementById('detail-cid').textContent = `#${cid}`;
|
||||||
|
document.getElementById('detail-link').href = `/cluster/${cid}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/campaigns/${cid}`);
|
const resp = await fetch(`/api/campaigns/${cid}`);
|
||||||
|
|||||||
264
services/dashboard/backend/templates/cluster_detail.html
Normal file
264
services/dashboard/backend/templates/cluster_detail.html
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}JA4 SOC — Cluster #{{ cid }}{% endblock %}
|
||||||
|
{% block page_title %}
|
||||||
|
Cluster #{{ cid }}
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Investigation Cluster</h4>
|
||||||
|
<p>Analyse complète d'un cluster détecté par HDBSCAN. Un cluster regroupe des IPs aux comportements similaires (features ML proches), indiquant une campagne coordonnée.</p>
|
||||||
|
<p><strong>Workflow :</strong> Vérifiez la convergence JA4/ASN → analysez les cibles → classifiez le cluster entier.</p>
|
||||||
|
<p class="doc-source">Sources : ml_detected_anomalies WHERE campaign_id=…</p>
|
||||||
|
</div></span>
|
||||||
|
{% endblock %}
|
||||||
|
{% block header_actions %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/campaigns" class="px-3 py-1 bg-gray-800 text-gray-300 rounded text-xs hover:text-white">← Tous les clusters</a>
|
||||||
|
<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>
|
||||||
|
<button id="cls-all-btn" class="px-3 py-1 bg-brand-600 text-white rounded text-xs font-medium hover:bg-brand-500">Classifier tout le cluster</button>
|
||||||
|
<span id="cls-result" class="text-xs self-center"></span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3" id="kpi-row"></div>
|
||||||
|
|
||||||
|
<!-- Row 1: Timeline + Threat breakdown -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="section-card lg:col-span-2">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Timeline du cluster
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Activité temporelle</h4>
|
||||||
|
<p>Détections et IPs actives par heure. Les bursts synchronisés confirment la coordination.</p>
|
||||||
|
<p class="doc-source">Source : ml_detected_anomalies GROUP BY hour</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body"><div id="timeline-chart" style="height:220px"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header"><span class="section-title">Menaces</span></div>
|
||||||
|
<div class="section-body"><div id="threat-chart" style="height:220px"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: JA4 convergence + ASN infrastructure + Host targets -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Convergence JA4
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Empreintes TLS du cluster</h4>
|
||||||
|
<p>Répartition des JA4 dans le cluster. Une seule JA4 dominante = même outil/framework. Diversité JA4 = evasion ou rotation.</p>
|
||||||
|
<p><strong>Action :</strong> Cliquez sur une JA4 pour l'investiguer.</p>
|
||||||
|
<p class="doc-source">Source : ml_detected_anomalies GROUP BY ja4</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div id="ja4-chart" style="height:180px"></div>
|
||||||
|
<div id="ja4-list" class="mt-2 space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Infrastructure (ASN)
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Systèmes autonomes</h4>
|
||||||
|
<p>D'où viennent les IPs du cluster. Un seul ASN datacenter = hébergé. ASN ISP variés = botnet distribué.</p>
|
||||||
|
<p class="doc-source">Source : ml_detected_anomalies GROUP BY asn_org</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body"><div id="asn-chart" style="height:250px"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header"><span class="section-title">Hosts ciblés</span></div>
|
||||||
|
<div class="section-body"><div id="host-chart" style="height:250px"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Member table -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Membres du cluster
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>IPs membres</h4>
|
||||||
|
<p>Toutes les IPs rattachées à ce cluster par HDBSCAN. Triez par score pour prioriser l'investigation.</p>
|
||||||
|
<p><strong>Action :</strong> Cliquez pour investiguer l'IP. Utilisez « Classifier tout le cluster » pour un traitement en masse.</p>
|
||||||
|
<p class="doc-source">Source : ml_detected_anomalies WHERE campaign_id=…</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500" id="member-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto" style="max-height:500px;overflow-y:auto">
|
||||||
|
<table class="data-table" id="member-table"><thead><tr>
|
||||||
|
<th style="width:16px"></th>
|
||||||
|
<th>IP</th><th>Score</th><th>Threat</th><th>JA4</th>
|
||||||
|
<th>Hits</th><th>Velocity</th><th>Fuzz</th>
|
||||||
|
<th>Host</th><th>ASN</th><th>Pays</th><th>Browser</th><th>Bot</th><th>Date</th>
|
||||||
|
</tr></thead><tbody id="member-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const CID = {{ cid }};
|
||||||
|
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',LEGITIMATE_BROWSER:'#22c55e',ANUBIS_DENY:'#dc2626'};
|
||||||
|
let clusterIPs = [];
|
||||||
|
|
||||||
|
async function loadCluster() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/cluster/' + CID);
|
||||||
|
const d = await r.json();
|
||||||
|
const p = d.profile || {};
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
document.getElementById('kpi-row').innerHTML = [
|
||||||
|
kpi('IPs', fmtNum(p.unique_ips)),
|
||||||
|
kpi('JA4 uniques', fmtNum(p.unique_ja4)),
|
||||||
|
kpi('Hosts ciblés', fmtNum(p.unique_hosts)),
|
||||||
|
kpi('ASNs', fmtNum(p.unique_asns)),
|
||||||
|
kpi('Score moyen', p.avg_score != null ? parseFloat(p.avg_score).toFixed(4) : '—'),
|
||||||
|
kpi('Hits totaux', fmtNum(p.total_hits)),
|
||||||
|
kpi('Menaces', fmtNum(p.threat_count)),
|
||||||
|
kpi('Bots connus', fmtNum(p.known_bot_count)),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
// Store IPs for bulk classify
|
||||||
|
clusterIPs = (p.ip_list || []).map(ip => String(ip).replace('::ffff:',''));
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
const tl = d.timeline || [];
|
||||||
|
if (tl.length) {
|
||||||
|
const ch = echarts.init(document.getElementById('timeline-chart'));
|
||||||
|
ch.setOption(ecBase({
|
||||||
|
tooltip: ecTooltip({trigger:'axis'}),
|
||||||
|
grid:{left:40,right:20,top:20,bottom:30},
|
||||||
|
xAxis:{type:'category',data:tl.map(r=>(r.hour||'').substring(11,16)),axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||||
|
yAxis:[
|
||||||
|
{type:'value',name:'Détections',nameTextStyle:{color:EC_TEXT,fontSize:10},axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||||
|
{type:'value',name:'IPs',nameTextStyle:{color:EC_TEXT,fontSize:10},axisLabel:{color:EC_TEXT,fontSize:10},splitLine:{show:false}}
|
||||||
|
],
|
||||||
|
series:[
|
||||||
|
{name:'Détections',type:'bar',data:tl.map(r=>r.detections),itemStyle:{color:'#6366f1'},barWidth:'60%'},
|
||||||
|
{name:'IPs actives',type:'line',yAxisIndex:1,data:tl.map(r=>r.active_ips),lineStyle:{color:'#f97316'},itemStyle:{color:'#f97316'},smooth:true}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threat pie
|
||||||
|
const threats = d.threats || [];
|
||||||
|
if (threats.length) {
|
||||||
|
const ch2 = echarts.init(document.getElementById('threat-chart'));
|
||||||
|
ch2.setOption(ecBase({
|
||||||
|
tooltip:ecTooltip({trigger:'item'}),
|
||||||
|
series:[{type:'pie',radius:['30%','70%'],
|
||||||
|
label:{color:EC_TEXT,fontSize:10},
|
||||||
|
data:threats.map(t=>({name:t.threat_level,value:t.cnt,itemStyle:{color:THREAT_COLORS[t.threat_level]||'#6b7280'}}))}]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// JA4 breakdown
|
||||||
|
const ja4s = d.ja4_breakdown || [];
|
||||||
|
if (ja4s.length) {
|
||||||
|
const ch3 = echarts.init(document.getElementById('ja4-chart'));
|
||||||
|
ch3.setOption(ecBase({
|
||||||
|
tooltip:ecTooltip({trigger:'item'}),
|
||||||
|
series:[{type:'pie',radius:['25%','65%'],
|
||||||
|
label:{color:EC_TEXT,fontSize:9,formatter:'{b|{b}}\n{c}',rich:{b:{fontSize:8,color:'#9ca3af'}}},
|
||||||
|
data:ja4s.slice(0,10).map(r=>({name:(r.ja4||'').substring(0,20),value:r.sessions,fullJa4:r.ja4}))}]
|
||||||
|
}));
|
||||||
|
ch3.on('click', params => {
|
||||||
|
if (params.data && params.data.fullJa4) window.location = '/ja4/' + encodeURIComponent(params.data.fullJa4);
|
||||||
|
});
|
||||||
|
// JA4 clickable list
|
||||||
|
document.getElementById('ja4-list').innerHTML = ja4s.slice(0,5).map(r =>
|
||||||
|
`<a href="/ja4/${encodeURIComponent(r.ja4)}" class="block text-xs font-mono text-purple-400 hover:underline truncate" title="${escapeHtml(r.ja4)}">${escapeHtml(r.ja4)} <span class="text-gray-500">(${r.sessions} sess, ${r.unique_ips} IPs)</span></a>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASN breakdown
|
||||||
|
const asns = d.asn_breakdown || [];
|
||||||
|
if (asns.length) {
|
||||||
|
const ch4 = echarts.init(document.getElementById('asn-chart'));
|
||||||
|
ch4.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),
|
||||||
|
grid:{left:10,right:30,top:5,bottom:5,containLabel:true},
|
||||||
|
yAxis:{type:'category',data:asns.map(r=>r.asn_org).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:100,overflow:'truncate'},axisLine:{show:false}},
|
||||||
|
xAxis:{type:'value',show:false},
|
||||||
|
series:[{type:'bar',data:asns.map(r=>r.sessions).reverse(),barWidth:'60%',itemStyle:{color:'#f97316'},
|
||||||
|
label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host breakdown
|
||||||
|
const hosts = d.host_breakdown || [];
|
||||||
|
if (hosts.length) {
|
||||||
|
const ch5 = echarts.init(document.getElementById('host-chart'));
|
||||||
|
ch5.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),
|
||||||
|
grid:{left:10,right:30,top:5,bottom:5,containLabel:true},
|
||||||
|
yAxis:{type:'category',data:hosts.map(r=>r.host).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:100,overflow:'truncate'},axisLine:{show:false}},
|
||||||
|
xAxis:{type:'value',show:false},
|
||||||
|
series:[{type:'bar',data:hosts.map(r=>r.sessions).reverse(),barWidth:'60%',itemStyle:{color:'#22c55e'},
|
||||||
|
label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member table
|
||||||
|
const members = d.members || [];
|
||||||
|
document.getElementById('member-count').textContent = members.length + ' membres';
|
||||||
|
document.getElementById('member-body').innerHTML = members.map(row => {
|
||||||
|
const ip = String(row.src_ip||'').replace('::ffff:','');
|
||||||
|
return `<tr onclick="window.location='/ip/${encodeURIComponent(ip)}'">
|
||||||
|
<td><span class="w-2 h-2 rounded-full inline-block" style="background:${THREAT_COLORS[row.threat_level]||'#6b7280'}"></span></td>
|
||||||
|
<td>${fmtIP(row.src_ip)}</td>
|
||||||
|
<td>${fmtScore(row.anomaly_score)}</td>
|
||||||
|
<td>${threatBadge(row.threat_level)}</td>
|
||||||
|
<td><a href="/ja4/${encodeURIComponent(row.ja4||'')}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline font-mono text-[11px]" title="${escapeHtml(row.ja4||'')}">${escapeHtml((row.ja4||'').substring(0,22))}…</a></td>
|
||||||
|
<td class="font-mono text-xs">${row.hits||0}</td>
|
||||||
|
<td class="font-mono text-xs">${parseFloat(row.hit_velocity||0).toFixed(1)}</td>
|
||||||
|
<td class="font-mono text-xs">${parseFloat(row.fuzzing_index||0).toFixed(3)}</td>
|
||||||
|
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
|
||||||
|
<td class="text-xs max-w-[100px] truncate">${fmtASN(row.asn_org)}</td>
|
||||||
|
<td>${fmtCountry(row.country_code)}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.browser_family||'')}</td>
|
||||||
|
<td class="text-xs">${row.bot_name ? fmtBotName(row.bot_name) : ''}</td>
|
||||||
|
<td class="text-[11px] text-gray-400">${(row.detected_at||'').substring(0,16)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('') || '<tr><td colspan="14" class="text-center text-gray-500 py-6">Aucun membre</td></tr>';
|
||||||
|
|
||||||
|
} catch(e) { console.error('Cluster load error:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpi(label, value) {
|
||||||
|
return `<div class="kpi-card"><div class="text-[10px] text-gray-500 uppercase tracking-wider mb-1">${label}</div><div class="text-lg font-bold text-white">${value}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk classify all IPs in cluster
|
||||||
|
document.getElementById('cls-all-btn').onclick = async () => {
|
||||||
|
if (!clusterIPs.length) return;
|
||||||
|
const type = document.getElementById('cls-select').value;
|
||||||
|
const result = document.getElementById('cls-result');
|
||||||
|
let ok = 0, fail = 0;
|
||||||
|
result.textContent = '⏳ Classification en cours…';
|
||||||
|
for (const ip of clusterIPs) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/classify', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ip, classification: type, comment: `Cluster #${CID} bulk classify`})
|
||||||
|
});
|
||||||
|
if (r.ok) ok++; else fail++;
|
||||||
|
} catch(e) { fail++; }
|
||||||
|
}
|
||||||
|
result.textContent = `✓ ${ok} IP(s) → ${type}` + (fail ? ` (${fail} erreurs)` : '');
|
||||||
|
result.className = 'text-xs self-center ' + (fail ? 'text-orange-400' : 'text-green-400');
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCluster();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
253
services/dashboard/backend/templates/ja4_detail.html
Normal file
253
services/dashboard/backend/templates/ja4_detail.html
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}JA4 SOC — JA4 {{ ja4[:24] }}…{% endblock %}
|
||||||
|
{% block page_title %}
|
||||||
|
Empreinte JA4
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Investigation JA4</h4>
|
||||||
|
<p>Analyse complète d'une empreinte JA4 : toutes les IPs l'utilisant, distribution des menaces, timeline, comportement réseau et logs HTTP.</p>
|
||||||
|
<p><strong>Workflow :</strong> Identifiez si l'empreinte est partagée par des bots (même JA4, IPs différentes) ou un navigateur légitime.</p>
|
||||||
|
<p class="doc-source">Sources : ml_all_scores, ml_detected_anomalies, http_logs</p>
|
||||||
|
</div></span>
|
||||||
|
{% endblock %}
|
||||||
|
{% block header_actions %}
|
||||||
|
<code class="text-xs text-purple-400 font-mono bg-gray-900 px-3 py-1.5 rounded-lg border border-gray-800 select-all max-w-[400px] truncate" title="{{ ja4 }}">{{ ja4 }}</code>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3" id="kpi-row"></div>
|
||||||
|
|
||||||
|
<!-- Row 1: Timeline + Threats -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="section-card lg:col-span-2">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Timeline activité
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Timeline JA4</h4>
|
||||||
|
<p>Sessions et IPs actives par heure utilisant cette empreinte. Un pic indique une campagne coordonnée.</p>
|
||||||
|
<p class="doc-source">Source : ml_all_scores GROUP BY hour</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body"><div id="timeline-chart" style="height:220px"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Distribution menaces
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Répartition des niveaux de menace</h4>
|
||||||
|
<p>Proportion des sessions classifiées par niveau. Une JA4 avec 100% HIGH est probablement un outil automatisé.</p>
|
||||||
|
<p class="doc-source">Source : ml_all_scores WHERE ja4=…</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body"><div id="threat-chart" style="height:220px"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: IPs using this JA4 + ASN/Geo -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="section-card lg:col-span-2">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
IPs associées
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>IPs utilisant cette empreinte</h4>
|
||||||
|
<p>Liste des IP sources ayant présenté cette JA4. Cliquez pour investiguer une IP. Un grand nombre d'IPs avec la même JA4 suggère un botnet.</p>
|
||||||
|
<p class="doc-source">Source : ml_all_scores</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500" id="ip-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto" style="max-height:400px;overflow-y:auto">
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th style="width:16px"></th>
|
||||||
|
<th>IP</th><th>Score</th><th>Threat</th><th>Hits</th>
|
||||||
|
<th>Host</th><th>ASN</th><th>Pays</th><th>Browser</th><th>Date</th>
|
||||||
|
</tr></thead><tbody id="scores-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header"><span class="section-title">Infrastructure (ASN)</span></div>
|
||||||
|
<div class="section-body"><div id="asn-chart" style="height:180px"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header"><span class="section-title">Géographie</span></div>
|
||||||
|
<div class="section-body"><div id="geo-chart" style="height:180px"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: HTTP Logs -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Logs HTTP récents
|
||||||
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||||||
|
<h4>Requêtes HTTP avec cette JA4</h4>
|
||||||
|
<p>Échantillon du trafic brut (24h). Analysez les paths, User-Agents et méthodes pour confirmer l'automatisation.</p>
|
||||||
|
<p class="doc-source">Source : http_logs WHERE ja4=…</p>
|
||||||
|
</div></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto" style="max-height:300px;overflow-y:auto">
|
||||||
|
<table class="data-table"><thead><tr>
|
||||||
|
<th>Time</th><th>IP</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP</th><th>User-Agent</th>
|
||||||
|
</tr></thead><tbody id="http-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: AI Features -->
|
||||||
|
<div class="section-card" id="features-section" style="display:none">
|
||||||
|
<div class="section-header"><span class="section-title">Features ML (par IP×Host)</span></div>
|
||||||
|
<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>Hits</th><th>Velocity</th><th>Fuzz</th>
|
||||||
|
<th>Post%</th><th>Browser</th><th>ASN</th><th>Label</th>
|
||||||
|
</tr></thead><tbody id="features-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const JA4 = '{{ ja4 }}';
|
||||||
|
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',LEGITIMATE_BROWSER:'#22c55e',ANUBIS_DENY:'#dc2626'};
|
||||||
|
|
||||||
|
async function loadJA4Detail() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/ja4/' + encodeURIComponent(JA4));
|
||||||
|
const d = await r.json();
|
||||||
|
const p = d.profile || {};
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
document.getElementById('kpi-row').innerHTML = [
|
||||||
|
kpi('Sessions', fmtNum(p.total_sessions)),
|
||||||
|
kpi('IPs uniques', fmtNum(p.unique_ips)),
|
||||||
|
kpi('Hosts ciblés', fmtNum(p.unique_hosts)),
|
||||||
|
kpi('Hits totaux', fmtNum(p.total_hits)),
|
||||||
|
kpi('Score moyen', p.avg_score != null ? parseFloat(p.avg_score).toFixed(4) : '—'),
|
||||||
|
kpi('Score max', p.max_score != null ? parseFloat(p.max_score).toFixed(4) : '—'),
|
||||||
|
kpi('Menaces', fmtNum(p.threat_count)),
|
||||||
|
kpi('Navigateurs', (p.browser_list||[]).join(', ') || '—'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
const tl = d.timeline || [];
|
||||||
|
if (tl.length) {
|
||||||
|
const ch = echarts.init(document.getElementById('timeline-chart'));
|
||||||
|
ch.setOption(ecBase({
|
||||||
|
tooltip: ecTooltip({trigger:'axis'}),
|
||||||
|
grid:{left:40,right:20,top:20,bottom:30},
|
||||||
|
xAxis:{type:'category',data:tl.map(r=>(r.hour||'').substring(11,16)),axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||||
|
yAxis:[
|
||||||
|
{type:'value',name:'Sessions',nameTextStyle:{color:EC_TEXT,fontSize:10},axisLabel:{color:EC_TEXT,fontSize:10}},
|
||||||
|
{type:'value',name:'IPs',nameTextStyle:{color:EC_TEXT,fontSize:10},axisLabel:{color:EC_TEXT,fontSize:10},splitLine:{show:false}}
|
||||||
|
],
|
||||||
|
series:[
|
||||||
|
{name:'Sessions',type:'bar',data:tl.map(r=>r.sessions),itemStyle:{color:'#6366f1'},barWidth:'60%'},
|
||||||
|
{name:'IPs actives',type:'line',yAxisIndex:1,data:tl.map(r=>r.active_ips),lineStyle:{color:'#f97316'},itemStyle:{color:'#f97316'},smooth:true}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threat pie
|
||||||
|
const threats = d.threats || [];
|
||||||
|
if (threats.length) {
|
||||||
|
const ch2 = echarts.init(document.getElementById('threat-chart'));
|
||||||
|
ch2.setOption(ecBase({
|
||||||
|
tooltip:ecTooltip({trigger:'item'}),
|
||||||
|
series:[{type:'pie',radius:['30%','70%'],
|
||||||
|
label:{color:EC_TEXT,fontSize:10},
|
||||||
|
data:threats.map(t=>({name:t.threat_level,value:t.cnt,itemStyle:{color:THREAT_COLORS[t.threat_level]||'#6b7280'}}))}]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scores table (all IPs with this JA4)
|
||||||
|
const scores = d.scores || [];
|
||||||
|
document.getElementById('ip-count').textContent = scores.length + ' sessions';
|
||||||
|
document.getElementById('scores-body').innerHTML = scores.map(row => {
|
||||||
|
const ip = String(row.src_ip||'').replace('::ffff:','');
|
||||||
|
return `<tr onclick="window.location='/ip/${encodeURIComponent(ip)}'">
|
||||||
|
<td><span class="w-2 h-2 rounded-full inline-block" style="background:${THREAT_COLORS[row.threat_level]||'#6b7280'}"></span></td>
|
||||||
|
<td>${fmtIP(row.src_ip)}</td>
|
||||||
|
<td>${fmtScore(row.anomaly_score)}</td>
|
||||||
|
<td>${threatBadge(row.threat_level)}</td>
|
||||||
|
<td class="font-mono text-xs">${row.hits||0}</td>
|
||||||
|
<td class="text-xs max-w-[120px] truncate">${escapeHtml(row.host||'')}</td>
|
||||||
|
<td class="text-xs max-w-[120px] truncate">${fmtASN(row.asn_org)}</td>
|
||||||
|
<td>${fmtCountry(row.country_code)}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.browser_family||'')}</td>
|
||||||
|
<td class="text-[11px] text-gray-400">${(row.detected_at||'').substring(0,16)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('') || '<tr><td colspan="10" class="text-center text-gray-500 py-6">Aucune session</td></tr>';
|
||||||
|
|
||||||
|
// ASN breakdown
|
||||||
|
const asnMap = {};
|
||||||
|
scores.forEach(r => { if(r.asn_org) asnMap[r.asn_org] = (asnMap[r.asn_org]||0)+1; });
|
||||||
|
const topASN = Object.entries(asnMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
|
||||||
|
if (topASN.length) {
|
||||||
|
const ch3 = echarts.init(document.getElementById('asn-chart'));
|
||||||
|
ch3.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),
|
||||||
|
grid:{left:10,right:30,top:5,bottom:5,containLabel:true},
|
||||||
|
yAxis:{type:'category',data:topASN.map(r=>r[0]).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:100,overflow:'truncate'},axisLine:{show:false}},
|
||||||
|
xAxis:{type:'value',show:false},
|
||||||
|
series:[{type:'bar',data:topASN.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#f97316'},
|
||||||
|
label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geo breakdown
|
||||||
|
const geoMap = {};
|
||||||
|
scores.forEach(r => { if(r.country_code) geoMap[r.country_code] = (geoMap[r.country_code]||0)+1; });
|
||||||
|
const topGeo = Object.entries(geoMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
|
||||||
|
if (topGeo.length) {
|
||||||
|
const ch4 = echarts.init(document.getElementById('geo-chart'));
|
||||||
|
ch4.setOption(ecBase({tooltip:ecTooltip({trigger:'item'}),
|
||||||
|
series:[{type:'pie',radius:['25%','65%'],
|
||||||
|
label:{color:EC_TEXT,fontSize:10},
|
||||||
|
data:topGeo.map(([k,v])=>({name:k,value:v}))}]}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP logs
|
||||||
|
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
|
||||||
|
<td class="text-[11px] whitespace-nowrap text-gray-400">${row.time||''}</td>
|
||||||
|
<td>${fmtIP(row.src_ip)}</td>
|
||||||
|
<td class="font-mono text-xs">${escapeHtml(row.method||'')}</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-[200px] truncate text-gray-400">${escapeHtml(row.header_user_agent||'')}</td>
|
||||||
|
</tr>`).join('') || '<tr><td colspan="7" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
|
||||||
|
|
||||||
|
// AI features
|
||||||
|
const feats = d.ai_features || [];
|
||||||
|
if (feats.length) {
|
||||||
|
document.getElementById('features-section').style.display = '';
|
||||||
|
document.getElementById('features-body').innerHTML = feats.map(row => {
|
||||||
|
const ip = String(row.src_ip||'').replace('::ffff:','');
|
||||||
|
return `<tr onclick="window.location='/ip/${encodeURIComponent(ip)}'">
|
||||||
|
<td>${fmtIP(row.src_ip)}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.host||'')}</td>
|
||||||
|
<td class="font-mono text-xs">${row.hits||0}</td>
|
||||||
|
<td class="font-mono text-xs">${parseFloat(row.hit_velocity||0).toFixed(2)}</td>
|
||||||
|
<td class="font-mono text-xs">${parseFloat(row.fuzzing_index||0).toFixed(3)}</td>
|
||||||
|
<td class="font-mono text-xs">${parseFloat(row.post_ratio||0).toFixed(3)}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.browser_family||'')}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.asn_org||'')}</td>
|
||||||
|
<td class="text-xs">${escapeHtml(row.asn_label||'')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('JA4 detail load error:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpi(label, value) {
|
||||||
|
return `<div class="kpi-card"><div class="text-[10px] text-gray-500 uppercase tracking-wider mb-1">${label}</div><div class="text-lg font-bold text-white">${value}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadJA4Detail();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user