feat: nouvelles techniques de détection et page tactiques SOC
SQL: - Ajout 5 colonnes d'agrégation (count_xff, count_unusual_ct, count_non_std_port, count_login_post, sec_ch_mobile_mismatch) - Exposition de 5 features calculées dans view_ai_features_1h - Migration ALTER TABLE pour déploiements existants Bot-detector: - 7 nouvelles features ML (has_xff, unusual_content_type_ratio, non_standard_port_ratio, login_post_concentration, sec_ch_mobile_mismatch, true_window_size, window_mss_ratio) - Propagation campaign_id vers ml_all_scores (était toujours -1) - Escalade campagne : HIGH→CRITICAL si cluster ≥5 membres Dashboard: - Page Tactiques SOC : brute-force, rotation JA4, récurrence, alertes temps réel — 4 KPIs + 4 panneaux + infobulles doc - Ajout fmtDate() helper global - Navigation sidebar mise à jour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -109,7 +109,8 @@ STRUCTURAL_EXCLUDED_FEATURES: dict[str, list] = {
|
|||||||
'request_size_variance', 'mss_mobile_mismatch',
|
'request_size_variance', 'mss_mobile_mismatch',
|
||||||
'ja3_diversity_ratio', 'syn_timing_cv', 'tls12_ratio', 'ip_df_variance',
|
'ja3_diversity_ratio', 'syn_timing_cv', 'tls12_ratio', 'ip_df_variance',
|
||||||
'avg_ttl', 'ttl_std', 'no_window_scale_ratio',
|
'avg_ttl', 'ttl_std', 'no_window_scale_ratio',
|
||||||
'ja4_drift_ratio'],
|
'ja4_drift_ratio',
|
||||||
|
'true_window_size', 'window_mss_ratio'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Imports optionnels (bibliothèques lourdes) ────────────────────────────
|
# ─── Imports optionnels (bibliothèques lourdes) ────────────────────────────
|
||||||
|
|||||||
@ -306,6 +306,23 @@ def run_semi_supervised_logic(df, features, name, cycle_id, recurrence_map):
|
|||||||
if ENABLE_CLUSTERING:
|
if ENABLE_CLUSTERING:
|
||||||
anomalies = cluster_anomalies(anomalies, scoring_features, ae_model=ae_model)
|
anomalies = cluster_anomalies(anomalies, scoring_features, ae_model=ae_model)
|
||||||
|
|
||||||
|
# P2 — Escalade par taille de campagne : les IPs dans un cluster
|
||||||
|
# coordonné de grande taille sont plus menaçantes que des IPs isolées.
|
||||||
|
# Escalader HIGH → CRITICAL si cluster_size ≥ 5.
|
||||||
|
if 'campaign_id' in anomalies.columns:
|
||||||
|
cid_counts = anomalies['campaign_id'].value_counts()
|
||||||
|
for cid, size in cid_counts.items():
|
||||||
|
if cid < 0:
|
||||||
|
continue
|
||||||
|
if size >= 5:
|
||||||
|
mask = (anomalies['campaign_id'] == cid) & (anomalies['threat_level'] == 'HIGH')
|
||||||
|
n_escalated = mask.sum()
|
||||||
|
if n_escalated > 0:
|
||||||
|
anomalies.loc[mask, 'threat_level'] = 'CRITICAL'
|
||||||
|
anomalies.loc[mask, 'reason'] = anomalies.loc[mask, 'reason'] + \
|
||||||
|
f' [Escalade campagne #{cid}, {size} IPs coordonnées]'
|
||||||
|
log_info(f"[{name}] Escalade campagne #{cid}: {n_escalated} IP(s) HIGH→CRITICAL ({size} membres)")
|
||||||
|
|
||||||
anomalies['ja4'] = anomalies['ja4'].replace({'': 'HTTP_CLEAR_TEXT'})
|
anomalies['ja4'] = anomalies['ja4'].replace({'': 'HTTP_CLEAR_TEXT'})
|
||||||
for _, row in anomalies.iterrows():
|
for _, row in anomalies.iterrows():
|
||||||
log_decision('ANOMALY', cycle_id, name, {
|
log_decision('ANOMALY', cycle_id, name, {
|
||||||
@ -330,6 +347,14 @@ def run_semi_supervised_logic(df, features, name, cycle_id, recurrence_map):
|
|||||||
anubis_deny if not anubis_deny.empty else None,
|
anubis_deny if not anubis_deny.empty else None,
|
||||||
] if df is not None], ignore_index=True)
|
] if df is not None], ignore_index=True)
|
||||||
|
|
||||||
|
# Propager campaign_id des anomalies clusterisées vers all_scored
|
||||||
|
# (all_scored a été capturé avant clustering, ses campaign_id sont tous -1)
|
||||||
|
if not anomalies.empty and 'campaign_id' in anomalies.columns:
|
||||||
|
cid_map = anomalies.set_index(anomalies.index)['campaign_id']
|
||||||
|
matched = all_scored.index.isin(cid_map.index)
|
||||||
|
if matched.any():
|
||||||
|
all_scored.loc[matched, 'campaign_id'] = cid_map
|
||||||
|
|
||||||
# Inclure anubis_allow dans all_scored pour traçabilité dans ml_all_scores.
|
# Inclure anubis_allow dans all_scored pour traçabilité dans ml_all_scores.
|
||||||
# Ces IPs sont exclues de l'analyse IF mais doivent apparaître dans la table
|
# Ces IPs sont exclues de l'analyse IF mais doivent apparaître dans la table
|
||||||
# de scores avec threat_level='KNOWN_BOT' et anomaly_score=0.0.
|
# de scores avec threat_level='KNOWN_BOT' et anomaly_score=0.0.
|
||||||
|
|||||||
@ -41,6 +41,12 @@ FEATURES = [
|
|||||||
'cadence_cv', 'burst_ratio', 'pause_ratio',
|
'cadence_cv', 'burst_ratio', 'pause_ratio',
|
||||||
'lag1_autocorrelation', 'benford_deviation',
|
'lag1_autocorrelation', 'benford_deviation',
|
||||||
'host_diversity', 'host_sweep_speed', 'host_coverage_uniformity',
|
'host_diversity', 'host_sweep_speed', 'host_coverage_uniformity',
|
||||||
|
# P0+P1 : features sous-exploitées (SQL existant ou ajouté)
|
||||||
|
'is_fake_navigation',
|
||||||
|
'true_window_size', 'window_mss_ratio',
|
||||||
|
# P1 : nouvelles features de détection
|
||||||
|
'has_xff', 'unusual_content_type_ratio', 'non_standard_port_ratio',
|
||||||
|
'login_post_concentration', 'sec_ch_mobile_mismatch',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Features supplémentaires pour le modèle Complet (données TCP/TLS requises)
|
# Features supplémentaires pour le modèle Complet (données TCP/TLS requises)
|
||||||
@ -100,6 +106,7 @@ def preprocess_df(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
'has_accept_language', 'has_cookie', 'has_referer', 'ua_ch_mismatch',
|
'has_accept_language', 'has_cookie', 'has_referer', 'ua_ch_mismatch',
|
||||||
'is_ua_rotating', 'is_alpn_missing', 'sni_host_mismatch', 'alpn_http_mismatch',
|
'is_ua_rotating', 'is_alpn_missing', 'sni_host_mismatch', 'alpn_http_mismatch',
|
||||||
'mss_mobile_mismatch', 'anubis_is_flagged', 'is_rare_ja4',
|
'mss_mobile_mismatch', 'anubis_is_flagged', 'is_rare_ja4',
|
||||||
|
'is_fake_navigation', 'has_xff', 'sec_ch_mobile_mismatch',
|
||||||
}
|
}
|
||||||
for col in df.columns:
|
for col in df.columns:
|
||||||
if col in binary_features:
|
if col in binary_features:
|
||||||
|
|||||||
17
services/correlator/sql/migrations/02_detection_features.sql
Normal file
17
services/correlator/sql/migrations/02_detection_features.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 02_detection_features.sql — Ajout des features de détection P0+P1
|
||||||
|
-- Colonnes supplémentaires dans agg_host_ip_ja4_1h et agg_header_fingerprint_1h
|
||||||
|
-- NOTE : les MVs doivent être recréées (DROP + CREATE) car ALTER VIEW n'existe pas.
|
||||||
|
-- Exécuter deploy_schema.sh pour recréer les MVs, ou relancer le schema complet.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- agg_host_ip_ja4_1h : nouvelles colonnes de comptage
|
||||||
|
ALTER TABLE ja4_processing.agg_host_ip_ja4_1h
|
||||||
|
ADD COLUMN IF NOT EXISTS count_xff SimpleAggregateFunction(sum, UInt64) AFTER count_http_scheme,
|
||||||
|
ADD COLUMN IF NOT EXISTS count_unusual_ct SimpleAggregateFunction(sum, UInt64) AFTER count_xff,
|
||||||
|
ADD COLUMN IF NOT EXISTS count_non_std_port SimpleAggregateFunction(sum, UInt64) AFTER count_unusual_ct,
|
||||||
|
ADD COLUMN IF NOT EXISTS count_login_post SimpleAggregateFunction(sum, UInt64) AFTER count_non_std_port;
|
||||||
|
|
||||||
|
-- agg_header_fingerprint_1h : mismatch mobile Sec-CH-UA
|
||||||
|
ALTER TABLE ja4_processing.agg_header_fingerprint_1h
|
||||||
|
ADD COLUMN IF NOT EXISTS sec_ch_mobile_mismatch SimpleAggregateFunction(max, UInt8) AFTER ua_ch_mismatch;
|
||||||
@ -71,3 +71,8 @@ async def ja4_detail_page(request: Request, fingerprint: str):
|
|||||||
@router.get("/cluster/{cid}")
|
@router.get("/cluster/{cid}")
|
||||||
async def cluster_detail_page(request: Request, cid: int):
|
async def cluster_detail_page(request: Request, cid: int):
|
||||||
return templates.TemplateResponse("cluster_detail.html", _ctx(request, "cluster_detail", cid=cid))
|
return templates.TemplateResponse("cluster_detail.html", _ctx(request, "cluster_detail", cid=cid))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tactics")
|
||||||
|
async def tactics_page(request: Request):
|
||||||
|
return templates.TemplateResponse("tactics.html", _ctx(request, "tactics"))
|
||||||
|
|||||||
@ -151,6 +151,10 @@
|
|||||||
<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="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>
|
<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="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>
|
||||||
<span class="nav-text">Campagnes</span>
|
<span class="nav-text">Campagnes</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/tactics" class="nav-item {% if active_page == 'tactics' %}active{% endif %}" title="Tactiques d'attaque détectées">
|
||||||
|
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
<span class="nav-text">Tactiques</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-group-title">Investigation</div>
|
<div class="nav-group-title">Investigation</div>
|
||||||
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
||||||
@ -324,6 +328,10 @@
|
|||||||
if (diff < 86400) return Math.round(diff/3600) + 'h';
|
if (diff < 86400) return Math.round(diff/3600) + 'h';
|
||||||
return Math.round(diff/86400) + 'j';
|
return Math.round(diff/86400) + 'j';
|
||||||
}
|
}
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '—';
|
||||||
|
return String(d).substring(0, 16).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
// ── ECharts helpers ──
|
// ── ECharts helpers ──
|
||||||
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
|
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
|
||||||
|
|||||||
199
services/dashboard/backend/templates/tactics.html
Normal file
199
services/dashboard/backend/templates/tactics.html
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Tactiques d'attaque{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- ═══ Tactiques d'attaque — 4 panneaux de détection spécialisés ═══ -->
|
||||||
|
|
||||||
|
<!-- ── KPIs ── -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Brute-force IPs</div><div class="kpi-value text-red-400" id="kpi-bf">—</div></div>
|
||||||
|
<div class="kpi-card"><div class="kpi-label">JA4 rotation IPs</div><div class="kpi-value text-orange-400" id="kpi-rot">—</div></div>
|
||||||
|
<div class="kpi-card"><div class="kpi-label">IPs récurrentes</div><div class="kpi-value text-yellow-400" id="kpi-rec">—</div></div>
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Alertes 24h</div><div class="kpi-value text-purple-400" id="kpi-alerts">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Brute-force / Credential stuffing ── -->
|
||||||
|
<div class="section-card border-red-600/40 mb-4">
|
||||||
|
<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>
|
||||||
|
<div class="relative">
|
||||||
|
<button class="doc-btn">ⓘ</button>
|
||||||
|
<div class="doc-panel right-0 w-72">
|
||||||
|
<strong>Détection brute-force</strong><br>
|
||||||
|
IPs envoyant ≥10 requêtes POST/heure sur des endpoints d'authentification.
|
||||||
|
Signale les tentatives de credential stuffing, brute-force de mots de passe,
|
||||||
|
ou abus d'API. Données issues de <code>view_form_bruteforce_detected</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>IP</th><th>Host</th><th class="text-right">POST/h</th><th class="text-right">Paths distincts</th><th>Première vue</th><th>Dernière vue</th></tr></thead>
|
||||||
|
<tbody id="bf-body"><tr><td colspan="6" class="text-center text-gray-500 py-8">Chargement…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── JA4 Rotation (évasion) ── -->
|
||||||
|
<div class="section-card border-orange-600/40 mb-4">
|
||||||
|
<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="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 de fingerprint JA4
|
||||||
|
</span>
|
||||||
|
<div class="relative">
|
||||||
|
<button class="doc-btn">ⓘ</button>
|
||||||
|
<div class="doc-panel right-0 w-72">
|
||||||
|
<strong>Détection d'évasion JA4</strong><br>
|
||||||
|
IPs utilisant ≥2 fingerprints JA4 distincts en 24h. Technique d'évasion
|
||||||
|
classique : rotation de la configuration TLS pour contourner les blocages
|
||||||
|
par fingerprint. Données issues de <code>view_host_ip_ja4_rotation</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>IP</th><th>Host</th><th class="text-right">JA4 distincts</th><th>Fingerprints</th><th class="text-right">Hits</th><th>Fenêtre</th></tr></thead>
|
||||||
|
<tbody id="rot-body"><tr><td colspan="6" class="text-center text-gray-500 py-8">Chargement…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Récurrence (menaces persistantes) ── -->
|
||||||
|
<div class="section-card border-yellow-600/40 mb-4">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
<svg class="w-4 h-4 text-yellow-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 (récurrence)
|
||||||
|
</span>
|
||||||
|
<div class="relative">
|
||||||
|
<button class="doc-btn">ⓘ</button>
|
||||||
|
<div class="doc-panel right-0 w-72">
|
||||||
|
<strong>IPs récurrentes</strong><br>
|
||||||
|
IPs détectées comme anomalies sur plusieurs fenêtres temporelles distinctes.
|
||||||
|
La récurrence augmente la confiance dans la classification malveillante.
|
||||||
|
Score agravé par <code>log1p(recurrence) × 0.005</code>.
|
||||||
|
Données issues de <code>view_ip_recurrence</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>IP</th><th class="text-right">Récurrence</th><th class="text-right">Pire score</th><th>Pire menace</th><th>Première vue</th><th>Dernière vue</th><th>JA4 top</th><th>Host top</th></tr></thead>
|
||||||
|
<tbody id="rec-body"><tr><td colspan="8" class="text-center text-gray-500 py-8">Chargement…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Alertes temps réel ── -->
|
||||||
|
<div class="section-card border-purple-600/40 mb-4">
|
||||||
|
<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="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 (24h)
|
||||||
|
</span>
|
||||||
|
<div class="relative">
|
||||||
|
<button class="doc-btn">ⓘ</button>
|
||||||
|
<div class="doc-panel right-0 w-72">
|
||||||
|
<strong>Flux d'alertes</strong><br>
|
||||||
|
Dernières détections CRITICAL, HIGH et KNOWN_BOT sur les 24 dernières heures.
|
||||||
|
Chaque alerte inclut le score, la raison SHAP, et le lien vers l'investigation IP.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Date</th><th>IP</th><th class="text-right">Score</th><th>Menace</th><th>JA4</th><th>Host</th><th>ASN</th><th class="text-right">Hits</th><th>Raison</th></tr></thead>
|
||||||
|
<tbody id="alert-body"><tr><td colspan="9" class="text-center text-gray-500 py-8">Chargement…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
* Tactiques — chargement des 4 sources de données
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/brute-force').then(r => r.json()),
|
||||||
|
fetch('/api/ja4-rotation').then(r => r.json()),
|
||||||
|
fetch('/api/recurrence').then(r => r.json()),
|
||||||
|
fetch('/api/alerts?limit=50').then(r => r.json()),
|
||||||
|
]).then(([bf, rot, rec, alerts]) => {
|
||||||
|
renderBruteForce(bf.data || []);
|
||||||
|
renderRotation(rot.data || []);
|
||||||
|
renderRecurrence(rec.data || []);
|
||||||
|
renderAlerts(alerts.alerts || []);
|
||||||
|
}).catch(err => console.error('Tactics load error:', err));
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderBruteForce(data) {
|
||||||
|
document.getElementById('kpi-bf').textContent = data.length;
|
||||||
|
const body = document.getElementById('bf-body');
|
||||||
|
if (!data.length) { body.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-8">Aucune tentative de brute-force détectée</td></tr>'; return; }
|
||||||
|
body.innerHTML = data.map(r => `<tr class="cursor-pointer hover:bg-gray-800/60" onclick="location.href='/ip/${r.src_ip}'">
|
||||||
|
<td>${fmtIP(r.src_ip)}</td><td class="text-gray-300">${escapeHtml(r.host||'')}</td>
|
||||||
|
<td class="text-right font-mono text-red-400 font-bold">${r.post_count}</td>
|
||||||
|
<td class="text-right">${r.distinct_paths}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${fmtDate(r.first_seen)}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${fmtDate(r.last_seen)}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRotation(data) {
|
||||||
|
document.getElementById('kpi-rot').textContent = data.length;
|
||||||
|
const body = document.getElementById('rot-body');
|
||||||
|
if (!data.length) { body.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-8">Aucune rotation JA4 détectée</td></tr>'; return; }
|
||||||
|
body.innerHTML = data.map(r => {
|
||||||
|
const ja4s = (r.ja4_list || []).map(j => fmtJA4(j)).join(', ');
|
||||||
|
return `<tr class="cursor-pointer hover:bg-gray-800/60" onclick="location.href='/ip/${r.src_ip}'">
|
||||||
|
<td>${fmtIP(r.src_ip)}</td><td class="text-gray-300">${escapeHtml(r.host||'')}</td>
|
||||||
|
<td class="text-right font-mono text-orange-400 font-bold">${r.distinct_ja4}</td>
|
||||||
|
<td class="max-w-xs truncate">${ja4s}</td>
|
||||||
|
<td class="text-right">${r.total_hits}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${fmtDate(r.window_start)}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecurrence(data) {
|
||||||
|
document.getElementById('kpi-rec').textContent = data.length;
|
||||||
|
const body = document.getElementById('rec-body');
|
||||||
|
if (!data.length) { body.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">Aucune IP récurrente</td></tr>'; return; }
|
||||||
|
body.innerHTML = data.map(r => `<tr class="cursor-pointer hover:bg-gray-800/60" onclick="location.href='/ip/${r.src_ip}'">
|
||||||
|
<td>${fmtIP(r.src_ip)}</td>
|
||||||
|
<td class="text-right font-mono text-yellow-400 font-bold">${r.recurrence}×</td>
|
||||||
|
<td class="text-right font-mono">${fmtScore(r.worst_score)}</td>
|
||||||
|
<td>${threatBadge(r.worst_threat||r.worst_threat_level||'')}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${fmtDate(r.first_seen)}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${fmtDate(r.last_seen)}</td>
|
||||||
|
<td>${r.top_ja4 ? fmtJA4(r.top_ja4) : ''}</td>
|
||||||
|
<td class="text-gray-300">${escapeHtml(r.top_host||'')}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(data) {
|
||||||
|
document.getElementById('kpi-alerts').textContent = data.length;
|
||||||
|
const body = document.getElementById('alert-body');
|
||||||
|
if (!data.length) { body.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">Aucune alerte récente</td></tr>'; return; }
|
||||||
|
body.innerHTML = data.map(r => `<tr class="cursor-pointer hover:bg-gray-800/60" onclick="location.href='/ip/${r.src_ip}'">
|
||||||
|
<td class="text-gray-400 text-xs whitespace-nowrap">${fmtDate(r.detected_at)}</td>
|
||||||
|
<td>${fmtIP(r.src_ip)}</td>
|
||||||
|
<td class="text-right font-mono">${fmtScore(r.anomaly_score)}</td>
|
||||||
|
<td>${threatBadge(r.threat_level)}</td>
|
||||||
|
<td>${fmtJA4(r.ja4)}</td>
|
||||||
|
<td class="text-gray-300">${escapeHtml(r.host||'')}</td>
|
||||||
|
<td class="text-gray-400 text-xs">${escapeHtml(r.asn_org||'')}</td>
|
||||||
|
<td class="text-right">${r.hits||''}</td>
|
||||||
|
<td class="text-gray-400 text-xs max-w-[200px] truncate" title="${escapeHtml(r.reason||'')}">${escapeHtml((r.reason||'').substring(0,60))}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -112,6 +112,11 @@ CREATE TABLE IF NOT EXISTS ja4_processing.agg_host_ip_ja4_1h
|
|||||||
-- HTTP features
|
-- HTTP features
|
||||||
count_no_accept_enc SimpleAggregateFunction(sum, UInt64),
|
count_no_accept_enc SimpleAggregateFunction(sum, UInt64),
|
||||||
count_http_scheme SimpleAggregateFunction(sum, UInt64),
|
count_http_scheme SimpleAggregateFunction(sum, UInt64),
|
||||||
|
-- P1 : nouvelles features de détection
|
||||||
|
count_xff SimpleAggregateFunction(sum, UInt64),
|
||||||
|
count_unusual_ct SimpleAggregateFunction(sum, UInt64),
|
||||||
|
count_non_std_port SimpleAggregateFunction(sum, UInt64),
|
||||||
|
count_login_post SimpleAggregateFunction(sum, UInt64),
|
||||||
|
|
||||||
-- Projection pour les requêtes d'investigation par IP :
|
-- Projection pour les requêtes d'investigation par IP :
|
||||||
-- ORDER BY actuel (window_start, src_ip, ...) est optimal pour heatmap
|
-- ORDER BY actuel (window_start, src_ip, ...) est optimal pour heatmap
|
||||||
@ -157,7 +162,7 @@ SELECT
|
|||||||
sum(IF(match(src.path, '(?i)\.(png|jpg|jpeg|gif|css|js|ico|woff2|svg|eot)$'), 1, 0)) AS count_assets,
|
sum(IF(match(src.path, '(?i)\.(png|jpg|jpeg|gif|css|js|ico|woff2|svg|eot)$'), 1, 0)) AS count_assets,
|
||||||
sum(IF(position(src.client_headers, 'Referer') = 0, 1, 0)) AS count_no_referer,
|
sum(IF(position(src.client_headers, 'Referer') = 0, 1, 0)) AS count_no_referer,
|
||||||
uniqState(src.header_user_agent) AS uniq_ua,
|
uniqState(src.header_user_agent) AS uniq_ua,
|
||||||
0 AS max_requests_per_sec,
|
0 AS max_requests_per_sec, -- TODO(P0): calculer via sous-requête par seconde (impossible dans un seul GROUP BY)
|
||||||
varPopState(toFloat64(length(replaceAll(src.path, '/', '//')) - length(src.path))) AS url_depth_variance,
|
varPopState(toFloat64(length(replaceAll(src.path, '/', '//')) - length(src.path))) AS url_depth_variance,
|
||||||
sum(IF(src.ip_meta_total_length < 60 OR src.ip_meta_total_length > 1500, 1, 0)) AS count_anomalous_payload,
|
sum(IF(src.ip_meta_total_length < 60 OR src.ip_meta_total_length > 1500, 1, 0)) AS count_anomalous_payload,
|
||||||
uniqState(src.ja3) AS uniq_ja3,
|
uniqState(src.ja3) AS uniq_ja3,
|
||||||
@ -173,7 +178,13 @@ SELECT
|
|||||||
sum(IF(src.tcp_meta_window_scale = 0 AND src.correlated = 1, 1, 0)) AS count_no_wscale,
|
sum(IF(src.tcp_meta_window_scale = 0 AND src.correlated = 1, 1, 0)) AS count_no_wscale,
|
||||||
sum(toUInt64(src.correlated)) AS count_correlated,
|
sum(toUInt64(src.correlated)) AS count_correlated,
|
||||||
sum(IF(length(src.header_accept_encoding) = 0, 1, 0)) AS count_no_accept_enc,
|
sum(IF(length(src.header_accept_encoding) = 0, 1, 0)) AS count_no_accept_enc,
|
||||||
sum(IF(src.scheme = 'http', 1, 0)) AS count_http_scheme
|
sum(IF(src.scheme = 'http', 1, 0)) AS count_http_scheme,
|
||||||
|
-- P1 : nouvelles features
|
||||||
|
sum(IF(length(src.header_x_forwarded_for) > 0, 1, 0)) AS count_xff,
|
||||||
|
sum(IF(src.method = 'POST' AND length(src.header_content_type) > 0
|
||||||
|
AND NOT match(src.header_content_type, '(?i)(form-urlencoded|multipart|json|xml|text/plain|grpc|protobuf)'), 1, 0)) AS count_unusual_ct,
|
||||||
|
sum(IF(src.dst_port NOT IN (80, 443, 8080, 8443), 1, 0)) AS count_non_std_port,
|
||||||
|
sum(IF(src.method = 'POST' AND match(src.path, '(?i)(login|signin|auth|token|session|wp-login|connect|oauth)'), 1, 0)) AS count_login_post
|
||||||
FROM ja4_logs.http_logs AS src
|
FROM ja4_logs.http_logs AS src
|
||||||
GROUP BY window_start, src_ip, ja4, host, src_asn;
|
GROUP BY window_start, src_ip, ja4, host, src_asn;
|
||||||
|
|
||||||
@ -192,6 +203,7 @@ CREATE TABLE IF NOT EXISTS ja4_processing.agg_header_fingerprint_1h
|
|||||||
has_referer SimpleAggregateFunction(max, UInt8),
|
has_referer SimpleAggregateFunction(max, UInt8),
|
||||||
modern_browser_score SimpleAggregateFunction(max, UInt8),
|
modern_browser_score SimpleAggregateFunction(max, UInt8),
|
||||||
ua_ch_mismatch SimpleAggregateFunction(max, UInt8),
|
ua_ch_mismatch SimpleAggregateFunction(max, UInt8),
|
||||||
|
sec_ch_mobile_mismatch SimpleAggregateFunction(max, UInt8),
|
||||||
sec_fetch_mode SimpleAggregateFunction(any, String),
|
sec_fetch_mode SimpleAggregateFunction(any, String),
|
||||||
sec_fetch_dest SimpleAggregateFunction(any, String)
|
sec_fetch_dest SimpleAggregateFunction(any, String)
|
||||||
)
|
)
|
||||||
@ -212,6 +224,10 @@ SELECT
|
|||||||
max(toUInt8(if(position(src.client_headers, 'Referer') > 0, 1, 0))) AS has_referer,
|
max(toUInt8(if(position(src.client_headers, 'Referer') > 0, 1, 0))) AS has_referer,
|
||||||
max(toUInt8(if(length(src.header_sec_ch_ua) > 0, 100, if(length(src.header_user_agent) > 0, 50, 0)))) AS modern_browser_score,
|
max(toUInt8(if(length(src.header_sec_ch_ua) > 0, 100, if(length(src.header_user_agent) > 0, 50, 0)))) AS modern_browser_score,
|
||||||
max(toUInt8(if((position(src.header_user_agent, 'Windows') > 0 AND position(src.header_sec_ch_ua_platform, 'Windows') == 0) OR (position(src.header_user_agent, 'iPhone') > 0 AND position(src.header_sec_ch_ua_platform, 'iOS') == 0), 1, 0))) AS ua_ch_mismatch,
|
max(toUInt8(if((position(src.header_user_agent, 'Windows') > 0 AND position(src.header_sec_ch_ua_platform, 'Windows') == 0) OR (position(src.header_user_agent, 'iPhone') > 0 AND position(src.header_sec_ch_ua_platform, 'iOS') == 0), 1, 0))) AS ua_ch_mismatch,
|
||||||
|
max(toUInt8(if(
|
||||||
|
(src.header_sec_ch_ua_mobile = '?1' AND position(src.header_user_agent, 'Mobile') == 0 AND position(src.header_user_agent, 'Android') == 0 AND position(src.header_user_agent, 'iPhone') == 0)
|
||||||
|
OR (src.header_sec_ch_ua_mobile = '?0' AND (position(src.header_user_agent, 'iPhone') > 0 OR position(src.header_user_agent, 'Android') > 0)),
|
||||||
|
1, 0))) AS sec_ch_mobile_mismatch,
|
||||||
any(src.header_sec_fetch_mode) AS sec_fetch_mode,
|
any(src.header_sec_fetch_mode) AS sec_fetch_mode,
|
||||||
any(src.header_sec_fetch_dest) AS sec_fetch_dest
|
any(src.header_sec_fetch_dest) AS sec_fetch_dest
|
||||||
FROM ja4_logs.http_logs AS src
|
FROM ja4_logs.http_logs AS src
|
||||||
|
|||||||
@ -127,7 +127,13 @@ WITH base_data AS (
|
|||||||
sqrt(a.ttl_variance_val) AS ttl_std,
|
sqrt(a.ttl_variance_val) AS ttl_std,
|
||||||
IF(a.count_correlated_val > 0, a.count_no_wscale_val / a.count_correlated_val, 0) AS no_window_scale_ratio,
|
IF(a.count_correlated_val > 0, a.count_no_wscale_val / a.count_correlated_val, 0) AS no_window_scale_ratio,
|
||||||
a.count_no_accept_enc_val / (a.hits + 1) AS missing_accept_enc_ratio,
|
a.count_no_accept_enc_val / (a.hits + 1) AS missing_accept_enc_ratio,
|
||||||
a.count_http_scheme_val / (a.hits + 1) AS http_scheme_ratio
|
a.count_http_scheme_val / (a.hits + 1) AS http_scheme_ratio,
|
||||||
|
-- P1 : nouvelles features de détection
|
||||||
|
IF(a.count_xff_val > 0, 1, 0) AS has_xff,
|
||||||
|
a.count_unusual_ct_val / greatest(a.count_post, 1) AS unusual_content_type_ratio,
|
||||||
|
a.count_non_std_port_val / (a.hits + 1) AS non_standard_port_ratio,
|
||||||
|
a.count_login_post_val / greatest(a.count_post, 1) AS login_post_concentration,
|
||||||
|
h.sec_ch_mobile_mismatch AS sec_ch_mobile_mismatch
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
window_start, src_ip, ja4, host, src_asn,
|
window_start, src_ip, ja4, host, src_asn,
|
||||||
@ -162,7 +168,12 @@ WITH base_data AS (
|
|||||||
sum(count_no_wscale) AS count_no_wscale_val,
|
sum(count_no_wscale) AS count_no_wscale_val,
|
||||||
sum(count_correlated) AS count_correlated_val,
|
sum(count_correlated) AS count_correlated_val,
|
||||||
sum(count_no_accept_enc) AS count_no_accept_enc_val,
|
sum(count_no_accept_enc) AS count_no_accept_enc_val,
|
||||||
sum(count_http_scheme) AS count_http_scheme_val
|
sum(count_http_scheme) AS count_http_scheme_val,
|
||||||
|
-- P1 : nouvelles features de détection
|
||||||
|
sum(count_xff) AS count_xff_val,
|
||||||
|
sum(count_unusual_ct) AS count_unusual_ct_val,
|
||||||
|
sum(count_non_std_port) AS count_non_std_port_val,
|
||||||
|
sum(count_login_post) AS count_login_post_val
|
||||||
FROM ja4_processing.agg_host_ip_ja4_1h
|
FROM ja4_processing.agg_host_ip_ja4_1h
|
||||||
WHERE window_start >= now() - INTERVAL 24 HOUR
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
GROUP BY window_start, src_ip, ja4, host, src_asn
|
GROUP BY window_start, src_ip, ja4, host, src_asn
|
||||||
@ -173,6 +184,7 @@ WITH base_data AS (
|
|||||||
max(header_count) AS header_count, max(has_accept_language) AS has_accept_language,
|
max(header_count) AS header_count, max(has_accept_language) AS has_accept_language,
|
||||||
max(has_cookie) AS has_cookie, max(has_referer) AS has_referer,
|
max(has_cookie) AS has_cookie, max(has_referer) AS has_referer,
|
||||||
max(modern_browser_score) AS modern_browser_score, max(ua_ch_mismatch) AS ua_ch_mismatch,
|
max(modern_browser_score) AS modern_browser_score, max(ua_ch_mismatch) AS ua_ch_mismatch,
|
||||||
|
max(sec_ch_mobile_mismatch) AS sec_ch_mobile_mismatch,
|
||||||
any(sec_fetch_mode) AS sec_fetch_mode, any(sec_fetch_dest) AS sec_fetch_dest
|
any(sec_fetch_mode) AS sec_fetch_mode, any(sec_fetch_dest) AS sec_fetch_dest
|
||||||
FROM ja4_processing.agg_header_fingerprint_1h
|
FROM ja4_processing.agg_header_fingerprint_1h
|
||||||
WHERE window_start >= now() - INTERVAL 24 HOUR
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
|||||||
Reference in New Issue
Block a user