maj cumulative
This commit is contained in:
@ -13,7 +13,7 @@ import os
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import db
|
from .database import db
|
||||||
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
|
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
|
||||||
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary
|
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary, search
|
||||||
|
|
||||||
# Configuration logging
|
# Configuration logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -83,6 +83,7 @@ app.include_router(botnets.router)
|
|||||||
app.include_router(rotation.router)
|
app.include_router(rotation.router)
|
||||||
app.include_router(ml_features.router)
|
app.include_router(ml_features.router)
|
||||||
app.include_router(investigation_summary.router)
|
app.include_router(investigation_summary.router)
|
||||||
|
app.include_router(search.router)
|
||||||
|
|
||||||
|
|
||||||
# Route pour servir le frontend
|
# Route pour servir le frontend
|
||||||
|
|||||||
@ -77,6 +77,10 @@ class Detection(BaseModel):
|
|||||||
client_headers: str = ""
|
client_headers: str = ""
|
||||||
asn_score: Optional[float] = None
|
asn_score: Optional[float] = None
|
||||||
asn_rep_label: str = ""
|
asn_rep_label: str = ""
|
||||||
|
first_seen: Optional[datetime] = None
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
unique_ja4s: Optional[List[str]] = None
|
||||||
|
unique_hosts: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class DetectionsListResponse(BaseModel):
|
class DetectionsListResponse(BaseModel):
|
||||||
|
|||||||
@ -19,7 +19,8 @@ async def get_detections(
|
|||||||
asn_number: Optional[str] = Query(None, description="Filtrer par ASN"),
|
asn_number: Optional[str] = Query(None, description="Filtrer par ASN"),
|
||||||
search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"),
|
search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"),
|
||||||
sort_by: str = Query("detected_at", description="Trier par"),
|
sort_by: str = Query("detected_at", description="Trier par"),
|
||||||
sort_order: str = Query("DESC", description="Ordre (ASC/DESC)")
|
sort_order: str = Query("DESC", description="Ordre (ASC/DESC)"),
|
||||||
|
group_by_ip: bool = Query(False, description="Grouper par IP (first_seen/last_seen agrégés)")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Récupère la liste des détections avec pagination et filtres
|
Récupère la liste des détections avec pagination et filtres
|
||||||
@ -47,7 +48,7 @@ async def get_detections(
|
|||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(src_ip ILIKE %(search)s OR ja4 ILIKE %(search)s OR host ILIKE %(search)s)"
|
"(ilike(toString(src_ip), %(search)s) OR ilike(ja4, %(search)s) OR ilike(host, %(search)s))"
|
||||||
)
|
)
|
||||||
params["search"] = f"%{search}%"
|
params["search"] = f"%{search}%"
|
||||||
|
|
||||||
@ -66,6 +67,124 @@ async def get_detections(
|
|||||||
# Requête principale
|
# Requête principale
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
|
||||||
|
|
||||||
|
# ── Mode groupé par IP (first_seen / last_seen depuis la DB) ────────────
|
||||||
|
if group_by_ip:
|
||||||
|
valid_sort_grouped = ["anomaly_score", "hits", "hit_velocity", "first_seen", "last_seen", "src_ip", "detected_at"]
|
||||||
|
grouped_sort = sort_by if sort_by in valid_sort_grouped else "last_seen"
|
||||||
|
# detected_at → last_seen (max(detected_at) dans le GROUP BY)
|
||||||
|
if grouped_sort == "detected_at":
|
||||||
|
grouped_sort = "last_seen"
|
||||||
|
# In outer query, min_score is exposed as anomaly_score — keep the alias
|
||||||
|
outer_sort = "min_score" if grouped_sort == "anomaly_score" else grouped_sort
|
||||||
|
|
||||||
|
# Count distinct IPs
|
||||||
|
count_ip_query = f"""
|
||||||
|
SELECT uniq(src_ip)
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE {where_clause}
|
||||||
|
"""
|
||||||
|
cr = db.query(count_ip_query, params)
|
||||||
|
total = cr.result_rows[0][0] if cr.result_rows else 0
|
||||||
|
|
||||||
|
grouped_query = f"""
|
||||||
|
SELECT
|
||||||
|
ip_data.src_ip,
|
||||||
|
ip_data.first_seen,
|
||||||
|
ip_data.last_seen,
|
||||||
|
ip_data.detection_count,
|
||||||
|
ip_data.unique_ja4s,
|
||||||
|
ip_data.unique_hosts,
|
||||||
|
ip_data.min_score AS anomaly_score,
|
||||||
|
ip_data.threat_level,
|
||||||
|
ip_data.model_name,
|
||||||
|
ip_data.country_code,
|
||||||
|
ip_data.asn_number,
|
||||||
|
ip_data.asn_org,
|
||||||
|
ip_data.hit_velocity,
|
||||||
|
ip_data.hits,
|
||||||
|
ip_data.asn_label,
|
||||||
|
ar.label AS asn_rep_label
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
src_ip,
|
||||||
|
min(detected_at) AS first_seen,
|
||||||
|
max(detected_at) AS last_seen,
|
||||||
|
count() AS detection_count,
|
||||||
|
groupUniqArray(5)(ja4) AS unique_ja4s,
|
||||||
|
groupUniqArray(5)(host) AS unique_hosts,
|
||||||
|
min(anomaly_score) AS min_score,
|
||||||
|
argMin(threat_level, anomaly_score) AS threat_level,
|
||||||
|
argMin(model_name, anomaly_score) AS model_name,
|
||||||
|
any(country_code) AS country_code,
|
||||||
|
any(asn_number) AS asn_number,
|
||||||
|
any(asn_org) AS asn_org,
|
||||||
|
max(hit_velocity) AS hit_velocity,
|
||||||
|
sum(hits) AS hits,
|
||||||
|
any(asn_label) AS asn_label
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE {where_clause}
|
||||||
|
GROUP BY src_ip
|
||||||
|
) ip_data
|
||||||
|
LEFT JOIN mabase_prod.asn_reputation ar
|
||||||
|
ON ar.src_asn = toUInt32OrZero(ip_data.asn_number)
|
||||||
|
ORDER BY {outer_sort} {sort_order}
|
||||||
|
LIMIT %(limit)s OFFSET %(offset)s
|
||||||
|
"""
|
||||||
|
params["limit"] = page_size
|
||||||
|
params["offset"] = offset
|
||||||
|
gresult = db.query(grouped_query, params)
|
||||||
|
|
||||||
|
def _label_to_score(label: str) -> float | None:
|
||||||
|
if not label: return None
|
||||||
|
mapping = {'human': 0.9, 'bot': 0.05, 'proxy': 0.25, 'vpn': 0.3,
|
||||||
|
'tor': 0.1, 'datacenter': 0.4, 'scanner': 0.05, 'malicious': 0.05}
|
||||||
|
return mapping.get(label.lower(), 0.5)
|
||||||
|
|
||||||
|
detections = []
|
||||||
|
for row in gresult.result_rows:
|
||||||
|
# row: src_ip, first_seen, last_seen, detection_count, unique_ja4s, unique_hosts,
|
||||||
|
# anomaly_score, threat_level, model_name, country_code, asn_number, asn_org,
|
||||||
|
# hit_velocity, hits, asn_label, asn_rep_label
|
||||||
|
ja4s = list(row[4]) if row[4] else []
|
||||||
|
hosts = list(row[5]) if row[5] else []
|
||||||
|
detections.append(Detection(
|
||||||
|
detected_at=row[1],
|
||||||
|
src_ip=str(row[0]),
|
||||||
|
ja4=ja4s[0] if ja4s else "",
|
||||||
|
host=hosts[0] if hosts else "",
|
||||||
|
bot_name="",
|
||||||
|
anomaly_score=float(row[6]) if row[6] else 0.0,
|
||||||
|
threat_level=row[7] or "LOW",
|
||||||
|
model_name=row[8] or "",
|
||||||
|
recurrence=int(row[3] or 0),
|
||||||
|
asn_number=str(row[10]) if row[10] else "",
|
||||||
|
asn_org=row[11] or "",
|
||||||
|
asn_detail="",
|
||||||
|
asn_domain="",
|
||||||
|
country_code=row[9] or "",
|
||||||
|
asn_label=row[14] or "",
|
||||||
|
hits=int(row[13] or 0),
|
||||||
|
hit_velocity=float(row[12]) if row[12] else 0.0,
|
||||||
|
fuzzing_index=0.0,
|
||||||
|
post_ratio=0.0,
|
||||||
|
reason="",
|
||||||
|
asn_rep_label=row[15] or "",
|
||||||
|
asn_score=_label_to_score(row[15] or ""),
|
||||||
|
first_seen=row[1],
|
||||||
|
last_seen=row[2],
|
||||||
|
unique_ja4s=ja4s,
|
||||||
|
unique_hosts=hosts,
|
||||||
|
))
|
||||||
|
|
||||||
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
return DetectionsListResponse(
|
||||||
|
items=detections, total=total, page=page,
|
||||||
|
page_size=page_size, total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Mode individuel (comportement original) ──────────────────────────────
|
||||||
# Validation du tri
|
# Validation du tri
|
||||||
valid_sort_columns = [
|
valid_sort_columns = [
|
||||||
"detected_at", "src_ip", "threat_level", "anomaly_score",
|
"detected_at", "src_ip", "threat_level", "anomaly_score",
|
||||||
@ -74,8 +193,6 @@ async def get_detections(
|
|||||||
if sort_by not in valid_sort_columns:
|
if sort_by not in valid_sort_columns:
|
||||||
sort_by = "detected_at"
|
sort_by = "detected_at"
|
||||||
|
|
||||||
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
|
|
||||||
|
|
||||||
main_query = f"""
|
main_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
detected_at,
|
detected_at,
|
||||||
|
|||||||
@ -33,8 +33,8 @@ async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
|
|||||||
any(a.ja4) AS ja4,
|
any(a.ja4) AS ja4,
|
||||||
any(a.host) AS host,
|
any(a.host) AS host,
|
||||||
sum(a.hits) AS hits,
|
sum(a.hits) AS hits,
|
||||||
round(max(uniqMerge(a.uniq_query_params))
|
round(uniqMerge(a.uniq_query_params)
|
||||||
/ greatest(max(uniqMerge(a.uniq_paths)), 1), 4) AS fuzzing_index,
|
/ greatest(uniqMerge(a.uniq_paths), 1), 4) AS fuzzing_index,
|
||||||
round(sum(a.hits)
|
round(sum(a.hits)
|
||||||
/ greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity,
|
/ greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity,
|
||||||
round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio,
|
round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio,
|
||||||
@ -378,16 +378,27 @@ async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
|
|||||||
try:
|
try:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
ip,
|
||||||
any(ja4) AS ja4,
|
ja4,
|
||||||
round(max(uniqMerge(uniq_query_params)) / greatest(max(uniqMerge(uniq_paths)), 1), 4) AS fuzzing_index,
|
round(fuzzing_index, 4) AS fuzzing_index,
|
||||||
round(sum(hits) / greatest(dateDiff('second', min(first_seen), max(last_seen)), 1), 2) AS hit_velocity,
|
round(total_hits / greatest(dateDiff('second', min_first, max_last), 1), 2) AS hit_velocity,
|
||||||
sum(hits) AS hits,
|
total_hits AS hits,
|
||||||
round(sum(count_head) / greatest(sum(hits), 1), 4) AS head_ratio,
|
round(total_count_head / greatest(total_hits, 1), 4) AS head_ratio,
|
||||||
max(correlated_raw) AS correlated
|
correlated
|
||||||
FROM mabase_prod.agg_host_ip_ja4_1h
|
FROM (
|
||||||
WHERE window_start >= now() - INTERVAL 24 HOUR
|
SELECT
|
||||||
GROUP BY src_ip
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
any(ja4) AS ja4,
|
||||||
|
uniqMerge(uniq_query_params) / greatest(uniqMerge(uniq_paths), 1) AS fuzzing_index,
|
||||||
|
sum(hits) AS total_hits,
|
||||||
|
min(first_seen) AS min_first,
|
||||||
|
max(last_seen) AS max_last,
|
||||||
|
sum(count_head) AS total_count_head,
|
||||||
|
max(correlated_raw) AS correlated
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||||
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY src_ip
|
||||||
|
)
|
||||||
ORDER BY fuzzing_index DESC
|
ORDER BY fuzzing_index DESC
|
||||||
LIMIT %(limit)s
|
LIMIT %(limit)s
|
||||||
"""
|
"""
|
||||||
|
|||||||
129
backend/routes/search.py
Normal file
129
backend/routes/search.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Endpoint de recherche globale rapide — utilisé par la barre Cmd+K
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from ..database import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||||
|
|
||||||
|
IP_RE = r"^(\d{1,3}\.){0,3}\d{1,3}$"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quick")
|
||||||
|
async def quick_search(q: str = Query(..., min_length=1, max_length=100)):
|
||||||
|
"""
|
||||||
|
Recherche unifiée sur IPs, JA4, ASN, hosts.
|
||||||
|
Retourne jusqu'à 5 résultats par catégorie.
|
||||||
|
"""
|
||||||
|
q = q.strip()
|
||||||
|
pattern = f"%{q}%"
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# ── IPs ──────────────────────────────────────────────────────────────────
|
||||||
|
ip_rows = db.query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
|
||||||
|
count() AS hits,
|
||||||
|
max(detected_at) AS last_seen,
|
||||||
|
any(threat_level) AS threat_level
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE ilike(toString(src_ip), %(p)s)
|
||||||
|
AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY clean_ip
|
||||||
|
ORDER BY hits DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
{"p": pattern},
|
||||||
|
)
|
||||||
|
for r in ip_rows.result_rows:
|
||||||
|
ip = str(r[0])
|
||||||
|
results.append({
|
||||||
|
"type": "ip",
|
||||||
|
"value": ip,
|
||||||
|
"label": ip,
|
||||||
|
"meta": f"{r[1]} détections · {r[3]}",
|
||||||
|
"url": f"/detections/ip/{ip}",
|
||||||
|
"investigation_url": f"/investigation/{ip}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── JA4 fingerprints ─────────────────────────────────────────────────────
|
||||||
|
ja4_rows = db.query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ja4,
|
||||||
|
count() AS hits,
|
||||||
|
uniq(src_ip) AS unique_ips
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE ilike(ja4, %(p)s)
|
||||||
|
AND ja4 != ''
|
||||||
|
AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY ja4
|
||||||
|
ORDER BY hits DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
{"p": pattern},
|
||||||
|
)
|
||||||
|
for r in ja4_rows.result_rows:
|
||||||
|
results.append({
|
||||||
|
"type": "ja4",
|
||||||
|
"value": str(r[0]),
|
||||||
|
"label": str(r[0]),
|
||||||
|
"meta": f"{r[1]} détections · {r[2]} IPs",
|
||||||
|
"url": f"/investigation/ja4/{r[0]}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Hosts ─────────────────────────────────────────────────────────────────
|
||||||
|
host_rows = db.query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
host,
|
||||||
|
count() AS hits,
|
||||||
|
uniq(src_ip) AS unique_ips
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE ilike(host, %(p)s)
|
||||||
|
AND host != ''
|
||||||
|
AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY host
|
||||||
|
ORDER BY hits DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
{"p": pattern},
|
||||||
|
)
|
||||||
|
for r in host_rows.result_rows:
|
||||||
|
results.append({
|
||||||
|
"type": "host",
|
||||||
|
"value": str(r[0]),
|
||||||
|
"label": str(r[0]),
|
||||||
|
"meta": f"{r[1]} hits · {r[2]} IPs",
|
||||||
|
"url": f"/detections?search={r[0]}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── ASN ───────────────────────────────────────────────────────────────────
|
||||||
|
asn_rows = db.query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
asn_org,
|
||||||
|
asn_number,
|
||||||
|
count() AS hits,
|
||||||
|
uniq(src_ip) AS unique_ips
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE (ilike(asn_org, %(p)s) OR ilike(asn_number, %(p)s))
|
||||||
|
AND asn_org != '' AND asn_number != ''
|
||||||
|
AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY asn_org, asn_number
|
||||||
|
ORDER BY hits DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
{"p": pattern},
|
||||||
|
)
|
||||||
|
for r in asn_rows.result_rows:
|
||||||
|
results.append({
|
||||||
|
"type": "asn",
|
||||||
|
"value": str(r[1]),
|
||||||
|
"label": f"AS{r[1]} — {r[0]}",
|
||||||
|
"meta": f"{r[2]} hits · {r[3]} IPs",
|
||||||
|
"url": f"/detections?asn={r[1]}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"query": q, "results": results}
|
||||||
@ -44,17 +44,22 @@ async def get_associated_ips(
|
|||||||
column = type_column_map[attr_type]
|
column = type_column_map[attr_type]
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT DISTINCT src_ip
|
SELECT src_ip, count() AS hit_count
|
||||||
FROM ml_detected_anomalies
|
FROM ml_detected_anomalies
|
||||||
WHERE {column} = %(value)s
|
WHERE {column} = %(value)s
|
||||||
AND detected_at >= now() - INTERVAL 24 HOUR
|
AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
ORDER BY src_ip
|
GROUP BY src_ip
|
||||||
|
ORDER BY hit_count DESC
|
||||||
LIMIT %(limit)s
|
LIMIT %(limit)s
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = db.query(query, {"value": value, "limit": limit})
|
result = db.query(query, {"value": value, "limit": limit})
|
||||||
|
|
||||||
ips = [str(row[0]) for row in result.result_rows]
|
total_hits = sum(row[1] for row in result.result_rows) or 1
|
||||||
|
ips = [
|
||||||
|
{"ip": str(row[0]), "count": row[1], "percentage": round(row[1] * 100.0 / total_hits, 2)}
|
||||||
|
for row in result.result_rows
|
||||||
|
]
|
||||||
|
|
||||||
# Compter le total
|
# Compter le total
|
||||||
count_query = f"""
|
count_query = f"""
|
||||||
@ -491,42 +496,77 @@ async def get_variability(attr_type: str, value: str):
|
|||||||
first_seen = stats_row[2]
|
first_seen = stats_row[2]
|
||||||
last_seen = stats_row[3]
|
last_seen = stats_row[3]
|
||||||
|
|
||||||
# User-Agents via view_dashboard_user_agents (source principale pour les UAs)
|
# User-Agents depuis http_logs pour des comptes exacts par requête
|
||||||
# Colonnes disponibles: src_ip, ja4, hour, log_date, user_agents, requests
|
# (view_dashboard_user_agents déduplique par heure, ce qui sous-compte les hits)
|
||||||
|
_ua_params: dict = {"value": value}
|
||||||
if attr_type == "ip":
|
if attr_type == "ip":
|
||||||
_ua_where = "toString(src_ip) = %(value)s"
|
_ua_logs_where = "src_ip = toIPv4(%(value)s)"
|
||||||
_ua_params: dict = {"value": value}
|
ua_query_simple = f"""
|
||||||
|
SELECT
|
||||||
|
header_user_agent AS user_agent,
|
||||||
|
count() AS count,
|
||||||
|
round(count() * 100.0 / (
|
||||||
|
SELECT count() FROM mabase_prod.http_logs
|
||||||
|
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
|
||||||
|
), 2) AS percentage,
|
||||||
|
min(time) AS first_seen,
|
||||||
|
max(time) AS last_seen
|
||||||
|
FROM mabase_prod.http_logs
|
||||||
|
WHERE {_ua_logs_where}
|
||||||
|
AND time >= now() - INTERVAL 24 HOUR
|
||||||
|
AND header_user_agent != '' AND header_user_agent IS NOT NULL
|
||||||
|
GROUP BY user_agent
|
||||||
|
ORDER BY count DESC
|
||||||
|
"""
|
||||||
|
ua_result = db.query(ua_query_simple, _ua_params)
|
||||||
|
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
|
||||||
elif attr_type == "ja4":
|
elif attr_type == "ja4":
|
||||||
_ua_where = "ja4 = %(value)s"
|
_ua_logs_where = "ja4 = %(value)s"
|
||||||
_ua_params = {"value": value}
|
ua_query_simple = f"""
|
||||||
|
SELECT
|
||||||
|
header_user_agent AS user_agent,
|
||||||
|
count() AS count,
|
||||||
|
round(count() * 100.0 / (
|
||||||
|
SELECT count() FROM mabase_prod.http_logs
|
||||||
|
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
|
||||||
|
), 2) AS percentage,
|
||||||
|
min(time) AS first_seen,
|
||||||
|
max(time) AS last_seen
|
||||||
|
FROM mabase_prod.http_logs
|
||||||
|
WHERE {_ua_logs_where}
|
||||||
|
AND time >= now() - INTERVAL 24 HOUR
|
||||||
|
AND header_user_agent != '' AND header_user_agent IS NOT NULL
|
||||||
|
GROUP BY user_agent
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
ua_result = db.query(ua_query_simple, _ua_params)
|
||||||
|
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
|
||||||
else:
|
else:
|
||||||
# country / asn / host: pivot via ml_detected_anomalies → IPs
|
# country / asn / host: pivot via ml_detected_anomalies → IPs, puis view UA
|
||||||
_ua_where = f"""toString(src_ip) IN (
|
_ua_where = f"""toString(src_ip) IN (
|
||||||
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
|
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
|
||||||
FROM ml_detected_anomalies
|
FROM ml_detected_anomalies
|
||||||
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
|
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
|
||||||
)"""
|
)"""
|
||||||
_ua_params = {"value": value}
|
ua_query_simple = f"""
|
||||||
|
SELECT
|
||||||
ua_query_simple = f"""
|
ua AS user_agent,
|
||||||
SELECT
|
sum(requests) AS count,
|
||||||
ua AS user_agent,
|
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
|
||||||
sum(requests) AS count,
|
min(log_date) AS first_seen,
|
||||||
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
|
max(log_date) AS last_seen
|
||||||
min(log_date) AS first_seen,
|
FROM view_dashboard_user_agents
|
||||||
max(log_date) AS last_seen
|
ARRAY JOIN user_agents AS ua
|
||||||
FROM view_dashboard_user_agents
|
WHERE {_ua_where}
|
||||||
ARRAY JOIN user_agents AS ua
|
AND hour >= now() - INTERVAL 24 HOUR
|
||||||
WHERE {_ua_where}
|
AND ua != ''
|
||||||
AND hour >= now() - INTERVAL 24 HOUR
|
GROUP BY user_agent
|
||||||
AND ua != ''
|
ORDER BY count DESC
|
||||||
GROUP BY user_agent
|
LIMIT 20
|
||||||
ORDER BY count DESC
|
"""
|
||||||
LIMIT 10
|
ua_result = db.query(ua_query_simple, _ua_params)
|
||||||
"""
|
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
|
||||||
|
|
||||||
ua_result = db.query(ua_query_simple, _ua_params)
|
|
||||||
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
|
|
||||||
|
|
||||||
# JA4 fingerprints
|
# JA4 fingerprints
|
||||||
ja4_query = f"""
|
ja4_query = f"""
|
||||||
|
|||||||
@ -137,6 +137,7 @@ export const detectionsApi = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
|
group_by_ip?: boolean;
|
||||||
}) => api.get<DetectionsListResponse>('/detections', { params }),
|
}) => api.get<DetectionsListResponse>('/detections', { params }),
|
||||||
|
|
||||||
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
|
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
|
||||||
|
|||||||
@ -10,21 +10,21 @@ export function DetailsView() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64 text-text-secondary">
|
||||||
<div className="text-text-secondary">Chargement...</div>
|
Chargement…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
|
<div className="bg-threat-critical_bg border border-threat-critical rounded-xl p-6">
|
||||||
<p className="text-threat-critical">Erreur: {error.message}</p>
|
<p className="text-threat-critical font-semibold mb-4">Erreur : {error.message}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/detections')}
|
onClick={() => navigate('/detections')}
|
||||||
className="mt-4 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm"
|
||||||
>
|
>
|
||||||
← Retour aux détections
|
← Retour
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -32,141 +32,124 @@ export function DetailsView() {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const typeLabels: Record<string, { label: string }> = {
|
const typeLabels: Record<string, string> = {
|
||||||
ip: { label: 'IP' },
|
ip: 'IP',
|
||||||
ja4: { label: 'JA4' },
|
ja4: 'JA4',
|
||||||
country: { label: 'Pays' },
|
country: 'Pays',
|
||||||
asn: { label: 'ASN' },
|
asn: 'ASN',
|
||||||
host: { label: 'Host' },
|
host: 'Host',
|
||||||
user_agent: { label: 'User-Agent' },
|
user_agent: 'User-Agent',
|
||||||
};
|
};
|
||||||
|
const typeLabel = typeLabels[type || ''] || type;
|
||||||
|
const isIP = type === 'ip';
|
||||||
|
const isJA4 = type === 'ja4';
|
||||||
|
|
||||||
const typeInfo = typeLabels[type || ''] || { label: type };
|
const first = data.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||||
|
const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
|
||||||
|
const sameDate = first && last && first.getTime() === last.getTime();
|
||||||
|
|
||||||
|
const fmtDate = (d: Date) =>
|
||||||
|
d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-5 animate-fade-in">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-2 text-sm text-text-secondary">
|
<nav className="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
<Link to="/" className="hover:text-text-primary transition-colors">Dashboard</Link>
|
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/detections" className="hover:text-text-primary transition-colors">Détections</Link>
|
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-text-primary">{typeInfo.label}: {value}</span>
|
<span className="text-text-primary">{typeLabel}: {value}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* En-tête */}
|
{/* Header card */}
|
||||||
<div className="bg-background-secondary rounded-lg p-6">
|
<div className="bg-background-secondary rounded-xl p-5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
{/* Identité */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-1">{typeLabel}</p>
|
||||||
{typeInfo.label}
|
<p className="text-lg font-mono font-bold text-text-primary break-all">{value}</p>
|
||||||
</h1>
|
|
||||||
<p className="font-mono text-text-secondary break-all">{value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-3xl font-bold text-text-primary">{data.total_detections}</div>
|
{/* Actions */}
|
||||||
<div className="text-text-secondary text-sm">détections (24h)</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
{type === 'ip' && value && (
|
{isIP && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/investigation/${encodeURIComponent(value)}`)}
|
onClick={() => navigate(`/investigation/${encodeURIComponent(value!)}`)}
|
||||||
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
|
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
🔍 Investigation complète
|
🔍 Investigation complète
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{type === 'ja4' && value && (
|
{isJA4 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value)}`)}
|
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value!)}`)}
|
||||||
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
|
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
🔍 Investigation JA4
|
🔍 Investigation JA4
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/detections')}
|
||||||
|
className="bg-background-card hover:bg-background-card/70 text-text-primary px-4 py-2 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats rapides */}
|
{/* Métriques clés */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-5">
|
||||||
<StatBox
|
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
|
||||||
label="IPs Uniques"
|
{!isIP && (
|
||||||
value={data.unique_ips.toLocaleString()}
|
<Metric label="IPs uniques" value={data.unique_ips.toLocaleString()} />
|
||||||
/>
|
)}
|
||||||
<StatBox
|
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
|
||||||
label="Première détection"
|
{first && last && (
|
||||||
value={formatDate(data.date_range.first_seen)}
|
sameDate ? (
|
||||||
/>
|
<Metric label="Détecté le" value={fmtDate(last)} />
|
||||||
<StatBox
|
) : (
|
||||||
label="Dernière détection"
|
<div className="bg-background-card rounded-xl p-3">
|
||||||
value={formatDate(data.date_range.last_seen)}
|
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
|
||||||
/>
|
<p className="text-xs text-text-primary font-medium">{fmtDate(first)}</p>
|
||||||
<StatBox
|
<p className="text-[10px] text-text-secondary">→ {fmtDate(last)}</p>
|
||||||
label="User-Agents"
|
</div>
|
||||||
value={data.attributes.user_agents.length.toString()}
|
)
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Insights + Variabilité côte à côte */}
|
{/* Insights */}
|
||||||
<div className="grid grid-cols-3 gap-6 items-start">
|
{data.insights.length > 0 && (
|
||||||
{data.insights.length > 0 && (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<div className="space-y-2">
|
{data.insights.map((ins, i) => {
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
|
const s: Record<string, string> = {
|
||||||
{data.insights.map((insight, i) => (
|
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
|
||||||
<InsightCard key={i} insight={insight} />
|
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
|
||||||
))}
|
success: 'bg-green-500/10 border-green-500/40 text-green-400',
|
||||||
</div>
|
};
|
||||||
)}
|
return (
|
||||||
|
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
|
||||||
<div className={data.insights.length > 0 ? 'col-span-2' : 'col-span-3'}>
|
{ins.message}
|
||||||
<VariabilityPanel attributes={data.attributes} />
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Bouton retour */}
|
{/* Attributs */}
|
||||||
<div className="flex justify-center">
|
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs={isIP} />
|
||||||
<button
|
|
||||||
onClick={() => navigate('/detections')}
|
|
||||||
className="bg-background-card hover:bg-background-card/80 text-text-primary px-6 py-3 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
← Retour aux détections
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composant StatBox
|
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||||
function StatBox({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background-card rounded-lg p-4">
|
<div className="bg-background-card rounded-xl p-3">
|
||||||
<div className="text-xl font-bold text-text-primary">{value}</div>
|
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
|
||||||
<div className="text-text-secondary text-xs">{label}</div>
|
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composant InsightCard
|
|
||||||
function InsightCard({ insight }: { insight: { type: string; message: string } }) {
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
warning: 'bg-yellow-500/10 border-yellow-500/50 text-yellow-500',
|
|
||||||
info: 'bg-blue-500/10 border-blue-500/50 text-blue-400',
|
|
||||||
success: 'bg-green-500/10 border-green-500/50 text-green-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles[insight.type] || styles.info} border rounded-lg p-4`}>
|
|
||||||
<span>{insight.message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper pour formater la date
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -42,8 +42,10 @@ export function DetectionsList() {
|
|||||||
const page = parseInt(searchParams.get('page') || '1');
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
const modelName = searchParams.get('model_name') || undefined;
|
const modelName = searchParams.get('model_name') || undefined;
|
||||||
const search = searchParams.get('search') || undefined;
|
const search = searchParams.get('search') || undefined;
|
||||||
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'anomaly_score') as SortField;
|
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
|
||||||
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'asc') as SortOrder;
|
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
|
||||||
|
|
||||||
|
const [groupByIP, setGroupByIP] = useState(true);
|
||||||
|
|
||||||
const { data, loading, error } = useDetections({
|
const { data, loading, error } = useDetections({
|
||||||
page,
|
page,
|
||||||
@ -52,13 +54,11 @@ export function DetectionsList() {
|
|||||||
search,
|
search,
|
||||||
sort_by: sortField,
|
sort_by: sortField,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
|
group_by_ip: groupByIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState(search || '');
|
const [searchInput, setSearchInput] = useState(search || '');
|
||||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||||
const [groupByIP, setGroupByIP] = useState(true); // Grouper par IP par défaut
|
|
||||||
|
|
||||||
// Configuration des colonnes
|
|
||||||
const [columns, setColumns] = useState<ColumnConfig[]>([
|
const [columns, setColumns] = useState<ColumnConfig[]>([
|
||||||
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
|
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
|
||||||
{ key: 'host', label: 'Host', visible: true, sortable: true },
|
{ key: 'host', label: 'Host', visible: true, sortable: true },
|
||||||
@ -107,6 +107,14 @@ export function DetectionsList() {
|
|||||||
setSearchParams(newParams);
|
setSearchParams(newParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (key: string, dir: 'asc' | 'desc') => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set('sort_by', key);
|
||||||
|
newParams.set('sort_order', dir);
|
||||||
|
newParams.set('page', '1');
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -125,59 +133,8 @@ export function DetectionsList() {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
// Traiter les données pour le regroupement par IP
|
// Backend handles grouping — data is already grouped when groupByIP=true
|
||||||
const processedData = (() => {
|
const processedData = data;
|
||||||
if (!groupByIP) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouper par IP
|
|
||||||
const ipGroups = new Map<string, typeof data.items[0]>();
|
|
||||||
const ipStats = new Map<string, {
|
|
||||||
first: Date;
|
|
||||||
last: Date;
|
|
||||||
count: number;
|
|
||||||
ja4s: Set<string>;
|
|
||||||
hosts: Set<string>;
|
|
||||||
clientHeaders: Set<string>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
data.items.forEach(item => {
|
|
||||||
if (!ipGroups.has(item.src_ip)) {
|
|
||||||
ipGroups.set(item.src_ip, item);
|
|
||||||
ipStats.set(item.src_ip, {
|
|
||||||
first: new Date(item.detected_at),
|
|
||||||
last: new Date(item.detected_at),
|
|
||||||
count: 1,
|
|
||||||
ja4s: new Set([item.ja4 || '']),
|
|
||||||
hosts: new Set([item.host || '']),
|
|
||||||
clientHeaders: new Set([item.client_headers || ''])
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const stats = ipStats.get(item.src_ip)!;
|
|
||||||
const itemDate = new Date(item.detected_at);
|
|
||||||
if (itemDate < stats.first) stats.first = itemDate;
|
|
||||||
if (itemDate > stats.last) stats.last = itemDate;
|
|
||||||
stats.count++;
|
|
||||||
if (item.ja4) stats.ja4s.add(item.ja4);
|
|
||||||
if (item.host) stats.hosts.add(item.host);
|
|
||||||
if (item.client_headers) stats.clientHeaders.add(item.client_headers);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
items: Array.from(ipGroups.values()).map(item => ({
|
|
||||||
...item,
|
|
||||||
hits: ipStats.get(item.src_ip)!.count,
|
|
||||||
first_seen: ipStats.get(item.src_ip)!.first.toISOString(),
|
|
||||||
last_seen: ipStats.get(item.src_ip)!.last.toISOString(),
|
|
||||||
unique_ja4s: Array.from(ipStats.get(item.src_ip)!.ja4s),
|
|
||||||
unique_hosts: Array.from(ipStats.get(item.src_ip)!.hosts),
|
|
||||||
unique_client_headers: Array.from(ipStats.get(item.src_ip)!.clientHeaders)
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Build DataTable columns from visible column configs
|
// Build DataTable columns from visible column configs
|
||||||
const tableColumns: Column<DetectionRow>[] = columns
|
const tableColumns: Column<DetectionRow>[] = columns
|
||||||
@ -352,20 +309,25 @@ export function DetectionsList() {
|
|||||||
label: col.label,
|
label: col.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
groupByIP && row.first_seen ? (
|
groupByIP && row.first_seen ? (() => {
|
||||||
<div className="space-y-1">
|
const first = new Date(row.first_seen!);
|
||||||
<div className="text-xs text-text-secondary">
|
const last = new Date(row.last_seen!);
|
||||||
<span className="font-medium">Premier:</span>{' '}
|
const sameTime = first.getTime() === last.getTime();
|
||||||
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
|
const fmt = (d: Date) =>
|
||||||
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
`${d.toLocaleDateString('fr-FR')} ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
return sameTime ? (
|
||||||
|
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-text-secondary">
|
||||||
|
<span className="font-medium">Premier:</span> {fmt(first)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary">
|
||||||
|
<span className="font-medium">Dernier:</span> {fmt(last)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-secondary">
|
);
|
||||||
<span className="font-medium">Dernier:</span>{' '}
|
})() : (
|
||||||
{new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '}
|
|
||||||
{new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-text-primary">
|
<div className="text-sm text-text-primary">
|
||||||
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
|
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
|
||||||
@ -388,7 +350,7 @@ export function DetectionsList() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
||||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
<span>{groupByIP ? processedData.items.length : data.items.length}</span>
|
<span>{data.items.length}</span>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
<span>{data.total} détections</span>
|
<span>{data.total} détections</span>
|
||||||
</div>
|
</div>
|
||||||
@ -403,8 +365,9 @@ export function DetectionsList() {
|
|||||||
? 'bg-accent-primary text-white border-accent-primary'
|
? 'bg-accent-primary text-white border-accent-primary'
|
||||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
|
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
||||||
>
|
>
|
||||||
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'}
|
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sélecteur de colonnes */}
|
{/* Sélecteur de colonnes */}
|
||||||
@ -486,7 +449,9 @@ export function DetectionsList() {
|
|||||||
data={processedData.items as DetectionRow[]}
|
data={processedData.items as DetectionRow[]}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
||||||
defaultSortKey="anomaly_score"
|
defaultSortKey={sortField}
|
||||||
|
defaultSortDir={sortOrder}
|
||||||
|
onSort={handleSort}
|
||||||
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
||||||
emptyMessage="Aucune détection trouvée"
|
emptyMessage="Aucune détection trouvée"
|
||||||
compact
|
compact
|
||||||
|
|||||||
@ -975,30 +975,28 @@ export function FingerprintsView() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items: JA4AttributeItem[] = data.items || [];
|
const items: JA4AttributeItem[] = data.items || [];
|
||||||
setJa4List(items);
|
setJa4List(items);
|
||||||
|
setLoadingList(false); // afficher la liste immédiatement
|
||||||
|
|
||||||
// Auto-enrich top 30 by count
|
// Enrichissement variability en arrière-plan (sans bloquer le rendu)
|
||||||
const top30 = items
|
const top30 = items
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 30);
|
.slice(0, 30);
|
||||||
|
|
||||||
await Promise.all(
|
top30.forEach(async (item) => {
|
||||||
top30.map(async (item) => {
|
try {
|
||||||
try {
|
const vRes = await fetch(
|
||||||
const vRes = await fetch(
|
`/api/variability/ja4/${encodeURIComponent(item.value)}`
|
||||||
`/api/variability/ja4/${encodeURIComponent(item.value)}`
|
);
|
||||||
);
|
if (!vRes.ok) return;
|
||||||
if (!vRes.ok) return;
|
const vData: VariabilityData = await vRes.json();
|
||||||
const vData: VariabilityData = await vRes.json();
|
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
|
||||||
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
|
} catch {
|
||||||
} catch {
|
// ignore individual errors
|
||||||
// ignore individual errors
|
}
|
||||||
}
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('FingerprintsView:', err);
|
console.error('FingerprintsView:', err);
|
||||||
} finally {
|
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1434,7 +1432,8 @@ interface PersistentThreat {
|
|||||||
interface JA4HistoryEntry {
|
interface JA4HistoryEntry {
|
||||||
ja4: string;
|
ja4: string;
|
||||||
hits: number;
|
hits: number;
|
||||||
window_start: string;
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SophisticationItem {
|
interface SophisticationItem {
|
||||||
@ -1544,7 +1543,7 @@ function RotatorRow({ item }: { item: JA4Rotator }) {
|
|||||||
<div key={idx} className="flex items-center gap-3 text-xs">
|
<div key={idx} className="flex items-center gap-3 text-xs">
|
||||||
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
|
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
|
||||||
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
|
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
|
||||||
<span className="text-text-disabled">{formatDate(entry.window_start)}</span>
|
<span className="text-text-disabled">{formatDate(entry.first_seen)} → {formatDate(entry.last_seen)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DataTable, { Column } from './ui/DataTable';
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
@ -88,6 +88,7 @@ export function HeaderFingerprintView() {
|
|||||||
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
||||||
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
||||||
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
||||||
|
const expandedPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchClusters = async () => {
|
const fetchClusters = async () => {
|
||||||
@ -113,6 +114,7 @@ export function HeaderFingerprintView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedHash(hash);
|
setExpandedHash(hash);
|
||||||
|
setTimeout(() => expandedPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
||||||
if (clusterIPsMap[hash] !== undefined) return;
|
if (clusterIPsMap[hash] !== undefined) return;
|
||||||
setLoadingHashes((prev) => new Set(prev).add(hash));
|
setLoadingHashes((prev) => new Set(prev).add(hash));
|
||||||
try {
|
try {
|
||||||
@ -289,13 +291,14 @@ export function HeaderFingerprintView() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="Aucun cluster détecté"
|
emptyMessage="Aucun cluster détecté"
|
||||||
compact
|
compact
|
||||||
|
maxHeight="max-h-[480px]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded IPs panel */}
|
{/* Expanded IPs panel */}
|
||||||
{expandedHash && (
|
{expandedHash && (
|
||||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
<div ref={expandedPanelRef} className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-text-primary">
|
<span className="text-sm font-semibold text-text-primary">
|
||||||
IPs du cluster{' '}
|
IPs du cluster{' '}
|
||||||
|
|||||||
@ -56,10 +56,10 @@ export function JA4InvestigationView() {
|
|||||||
unique_ips: ipsData.total || 0,
|
unique_ips: ipsData.total || 0,
|
||||||
first_seen: baseData.date_range?.first_seen || '',
|
first_seen: baseData.date_range?.first_seen || '',
|
||||||
last_seen: baseData.date_range?.last_seen || '',
|
last_seen: baseData.date_range?.last_seen || '',
|
||||||
top_ips: ipsData.ips?.slice(0, 10).map((ip: string) => ({
|
top_ips: ipsData.ips?.slice(0, 10).map((item: any) => ({
|
||||||
ip,
|
ip: typeof item === 'string' ? item : item.ip,
|
||||||
count: 0,
|
count: typeof item === 'string' ? 0 : (item.count || 0),
|
||||||
percentage: 0
|
percentage: typeof item === 'string' ? 0 : (item.percentage || 0)
|
||||||
})) || [],
|
})) || [],
|
||||||
top_countries: countriesData.items?.map((item: any) => ({
|
top_countries: countriesData.items?.map((item: any) => ({
|
||||||
code: item.value,
|
code: item.value,
|
||||||
|
|||||||
@ -2,66 +2,57 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
type: 'ip' | 'ja4' | 'asn' | 'host' | 'user_agent';
|
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||||
value: string;
|
value: string;
|
||||||
count?: number;
|
label: string;
|
||||||
threat_level?: string;
|
meta: string;
|
||||||
|
url: string;
|
||||||
|
investigation_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuickSearchProps {
|
interface QuickSearchProps {
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<string, string> = { ip: '🌐', ja4: '🔏', host: '🖥️', asn: '🏢' };
|
||||||
|
const TYPE_COLOR: Record<string, string> = {
|
||||||
|
ip: 'bg-blue-500/20 text-blue-400',
|
||||||
|
ja4: 'bg-purple-500/20 text-purple-400',
|
||||||
|
host: 'bg-green-500/20 text-green-400',
|
||||||
|
asn: 'bg-orange-500/20 text-orange-400',
|
||||||
|
};
|
||||||
|
|
||||||
export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Détection du type de recherche
|
// Recherche via le nouvel endpoint unifié
|
||||||
const detectType = (value: string): 'ip' | 'ja4' | 'asn' | 'host' | 'other' => {
|
|
||||||
// IPv4 pattern
|
|
||||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)) return 'ip';
|
|
||||||
// IPv6 pattern (simplified)
|
|
||||||
if (/^[0-9a-fA-F:]+$/.test(value) && value.includes(':')) return 'ip';
|
|
||||||
// JA4 pattern
|
|
||||||
if (/^t[0-9a-f]{2}[0-9a-f]{4}/.test(value)) return 'ja4';
|
|
||||||
// ASN pattern
|
|
||||||
if (/^AS?\d+$/i.test(value)) return 'asn';
|
|
||||||
// Host pattern
|
|
||||||
if (/\.[a-z]{2,}$/.test(value)) return 'host';
|
|
||||||
return 'other';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recherche automatique
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.length < 3) {
|
if (query.length < 2) { setResults([]); return; }
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const type = detectType(query);
|
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
|
||||||
const endpoint = type === 'other' ? 'ip' : type;
|
if (res.ok) {
|
||||||
const response = await fetch(`/api/attributes/${endpoint}?limit=5`);
|
const data = await res.json();
|
||||||
if (response.ok) {
|
setResults(data.results || []);
|
||||||
const data = await response.json();
|
setSelectedIndex(-1);
|
||||||
const items = data.items || data || [];
|
|
||||||
setResults(Array.isArray(items) ? items : []);
|
|
||||||
} else {
|
|
||||||
setResults([]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Search error:', error);
|
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 250);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
// Raccourci clavier Cmd+K
|
// Raccourci clavier Cmd+K
|
||||||
@ -72,52 +63,39 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
|
||||||
setIsOpen(false);
|
|
||||||
setQuery('');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Navigation au clavier
|
// Navigation clavier dans les résultats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return;
|
if (!isOpen || results.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
|
||||||
|
if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
|
handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
||||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(results[selectedIndex]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, results, selectedIndex]);
|
}, [isOpen, results, selectedIndex]);
|
||||||
|
|
||||||
// Click outside
|
// Click en dehors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelect = (result: SearchResult) => {
|
const handleSelect = (result: SearchResult, useInvestigation = false) => {
|
||||||
const type = result.type || detectType(result.value);
|
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||||
navigate(`/investigate/${type}/${encodeURIComponent(result.value)}`);
|
navigate(url);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
@ -125,53 +103,24 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (query.length >= 3) {
|
if (results[0]) handleSelect(results[0]);
|
||||||
const type = detectType(query);
|
|
||||||
navigate(`/investigate/${type}/${encodeURIComponent(query)}`);
|
|
||||||
setIsOpen(false);
|
|
||||||
onNavigate?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'ip': return '🌐';
|
|
||||||
case 'ja4': return '🔐';
|
|
||||||
case 'asn': return '🏢';
|
|
||||||
case 'host': return '🖥️';
|
|
||||||
case 'user_agent': return '🤖';
|
|
||||||
default: return '🔍';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'ip': return 'bg-blue-500/20 text-blue-400';
|
|
||||||
case 'ja4': return 'bg-purple-500/20 text-purple-400';
|
|
||||||
case 'asn': return 'bg-orange-500/20 text-orange-400';
|
|
||||||
case 'host': return 'bg-green-500/20 text-green-400';
|
|
||||||
default: return 'bg-gray-500/20 text-gray-400';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative w-full max-w-2xl">
|
<div ref={containerRef} className="relative w-full max-w-2xl">
|
||||||
{/* Search Bar */}
|
{/* Barre de recherche */}
|
||||||
<form onSubmit={handleSubmit} className="relative">
|
<form onSubmit={handleSubmit} className="relative">
|
||||||
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus:border-accent-primary transition-colors">
|
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus-within:border-accent-primary transition-colors">
|
||||||
<span className="pl-4 text-text-secondary">🔍</span>
|
<span className="pl-4 text-text-secondary">{loading ? <span className="animate-pulse">⌛</span> : '🔍'}</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
|
||||||
setQuery(e.target.value);
|
|
||||||
setIsOpen(true);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}}
|
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
|
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
|
||||||
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
|
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
|
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
|
||||||
<span>⌘</span>K
|
<span>⌘</span>K
|
||||||
@ -179,79 +128,52 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Results Dropdown */}
|
{/* Dropdown résultats */}
|
||||||
{isOpen && (query.length >= 3) && (
|
{isOpen && query.length >= 2 && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
|
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-xl shadow-2xl z-50 max-h-96 overflow-y-auto">
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<div className="py-2">
|
<ul className="py-1">
|
||||||
<div className="px-4 py-2 text-xs text-text-secondary border-b border-background-card">
|
{results.map((result, i) => (
|
||||||
Résultats suggérés
|
<li key={`${result.type}-${result.value}-${i}`}>
|
||||||
</div>
|
<button
|
||||||
{results.map((result, index) => (
|
onClick={() => handleSelect(result)}
|
||||||
<button
|
className={[
|
||||||
key={`${result.type}-${result.value}`}
|
'w-full flex items-center gap-3 px-4 py-2.5 transition-colors text-left',
|
||||||
onClick={() => handleSelect(result)}
|
i === selectedIndex ? 'bg-accent-primary/10 border-l-2 border-accent-primary' : 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||||
className={`w-full px-4 py-3 flex items-center gap-3 hover:bg-background-card transition-colors ${
|
].join(' ')}
|
||||||
index === selectedIndex ? 'bg-background-card' : ''
|
onMouseEnter={() => setSelectedIndex(i)}
|
||||||
}`}
|
>
|
||||||
>
|
<span className="text-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
|
||||||
<span className="text-xl">{getTypeIcon(result.type)}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 text-left">
|
<div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
|
||||||
<div className="font-mono text-sm text-text-primary">{result.value}</div>
|
<div className="text-xs text-text-secondary">{result.meta}</div>
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
{result.type} {result.count && `• ${result.count} détections`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
|
||||||
<span className={`px-2 py-1 rounded text-xs ${getTypeColor(result.type)}`}>
|
{result.type.toUpperCase()}
|
||||||
{result.type.toUpperCase()}
|
</span>
|
||||||
</span>
|
{result.investigation_url && (
|
||||||
</button>
|
<button
|
||||||
|
className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
|
||||||
|
onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
|
||||||
|
title="Investigation complète"
|
||||||
|
>→</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
) : !loading ? (
|
||||||
|
<div className="px-4 py-6 text-center text-text-disabled text-sm">
|
||||||
|
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="px-4 py-8 text-center text-text-secondary">
|
|
||||||
<div className="text-2xl mb-2">🔍</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
Tapez pour rechercher une IP, JA4, ASN, Host...
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-2">
|
|
||||||
Appuyez sur Entrée pour rechercher "{query}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Hints */}
|
||||||
<div className="border-t border-background-card px-4 py-3">
|
<div className="border-t border-background-card px-4 py-2 flex items-center gap-3 text-xs text-text-disabled">
|
||||||
<div className="text-xs text-text-secondary mb-2">Actions rapides</div>
|
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||||
<button
|
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||||
onClick={() => {
|
<span className="ml-auto opacity-60">24h</span>
|
||||||
navigate('/incidents?threat_level=CRITICAL');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 bg-threat-critical/20 text-threat-critical rounded text-xs hover:bg-threat-critical/30 transition-colors"
|
|
||||||
>
|
|
||||||
🔴 Menaces Critiques
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/detections');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 bg-accent-primary/20 text-accent-primary rounded text-xs hover:bg-accent-primary/30 transition-colors"
|
|
||||||
>
|
|
||||||
🔍 Investigation avancée
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/threat-intel');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 bg-purple-500/20 text-purple-400 rounded text-xs hover:bg-purple-500/30 transition-colors"
|
|
||||||
>
|
|
||||||
📚 Threat Intel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
182
frontend/src/components/SearchModal.tsx
Normal file
182
frontend/src/components/SearchModal.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
url: string;
|
||||||
|
investigation_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
ip: '🌐',
|
||||||
|
ja4: '🔏',
|
||||||
|
host: '🖥️',
|
||||||
|
asn: '🏢',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
ip: 'IP',
|
||||||
|
ja4: 'JA4',
|
||||||
|
host: 'Host',
|
||||||
|
asn: 'ASN',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SearchModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchModal({ open, onClose }: SearchModalProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Focus input when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setSelected(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const search = useCallback(async (q: string) => {
|
||||||
|
if (q.length < 2) { setResults([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.results || []);
|
||||||
|
setSelected(0);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setQuery(val);
|
||||||
|
if (debounce.current) clearTimeout(debounce.current);
|
||||||
|
debounce.current = setTimeout(() => search(val), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = (result: SearchResult, useInvestigation = false) => {
|
||||||
|
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||||
|
navigate(url);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') { onClose(); return; }
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
|
||||||
|
if (e.key === 'Enter' && results[selected]) {
|
||||||
|
go(results[selected], e.metaKey || e.ctrlKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-2xl bg-background-secondary border border-background-card rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-background-card">
|
||||||
|
<span className="text-text-disabled text-lg">🔍</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Rechercher IP, JA4, host, ASN..."
|
||||||
|
className="flex-1 bg-transparent text-text-primary placeholder-text-disabled outline-none text-base"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<span className="text-text-disabled text-xs animate-pulse">…</span>
|
||||||
|
)}
|
||||||
|
<kbd className="hidden sm:inline text-xs text-text-disabled bg-background-card px-1.5 py-0.5 rounded border border-border">
|
||||||
|
Esc
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="max-h-96 overflow-y-auto py-2">
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<li key={`${r.type}-${r.value}-${i}`}>
|
||||||
|
<button
|
||||||
|
className={[
|
||||||
|
'w-full flex items-start gap-3 px-4 py-2.5 text-left transition-colors',
|
||||||
|
i === selected
|
||||||
|
? 'bg-accent-primary/10 border-l-2 border-accent-primary'
|
||||||
|
: 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||||
|
].join(' ')}
|
||||||
|
onMouseEnter={() => setSelected(i)}
|
||||||
|
onClick={() => go(r)}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-base">{TYPE_ICON[r.type]}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase text-text-disabled tracking-wider">
|
||||||
|
{TYPE_LABEL[r.type]}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-text-primary truncate">{r.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary mt-0.5">{r.meta}</div>
|
||||||
|
</div>
|
||||||
|
{r.investigation_url && (
|
||||||
|
<button
|
||||||
|
className="shrink-0 text-xs text-accent-primary hover:underline"
|
||||||
|
onClick={e => { e.stopPropagation(); go(r, true); }}
|
||||||
|
title="Ouvrir l'investigation complète"
|
||||||
|
>
|
||||||
|
Investigation →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{query.length >= 2 && !loading && results.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-text-disabled text-sm">
|
||||||
|
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer hints */}
|
||||||
|
<div className="px-4 py-2 border-t border-background-card flex items-center gap-4 text-xs text-text-disabled">
|
||||||
|
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||||
|
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||||
|
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||||
|
<span className="ml-auto">Recherche sur les 24 dernières heures</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -41,7 +41,7 @@ export function ThreatIntelView() {
|
|||||||
const statsResponse = await fetch('/api/analysis/classifications/stats');
|
const statsResponse = await fetch('/api/analysis/classifications/stats');
|
||||||
if (statsResponse.ok) {
|
if (statsResponse.ok) {
|
||||||
const data = await statsResponse.json();
|
const data = await statsResponse.json();
|
||||||
setStats(data.items || []);
|
setStats(data.stats || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching threat intel:', error);
|
console.error('Error fetching threat intel:', error);
|
||||||
@ -248,7 +248,7 @@ export function ThreatIntelView() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||||
{getLabelIcon(classification.label)} {classification.label.toUpperCase()}
|
{getLabelIcon(classification.label)} {(classification.label ?? '').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
|||||||
@ -4,163 +4,146 @@ import { VariabilityAttributes, AttributeValue } from '../api/client';
|
|||||||
|
|
||||||
interface VariabilityPanelProps {
|
interface VariabilityPanelProps {
|
||||||
attributes: VariabilityAttributes;
|
attributes: VariabilityAttributes;
|
||||||
|
/** When true, hides the "Voir IPs associées" button (e.g. when already on an IP page) */
|
||||||
|
hideAssociatedIPs?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
|
export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
|
||||||
const [showModal, setShowModal] = useState<{
|
const [modal, setModal] = useState<{
|
||||||
type: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
items: string[];
|
items: string[];
|
||||||
total: number;
|
total: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Fonction pour charger la liste des IPs associées
|
|
||||||
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
|
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
setShowModal({
|
setModal({
|
||||||
type: 'ips',
|
title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
|
||||||
title: `${data.total || total} IPs associées à ${value}`,
|
|
||||||
items: data.ips || [],
|
items: data.ips || [],
|
||||||
total: data.total || total,
|
total: data.total || total,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Erreur chargement IPs:', error);
|
// ignore
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sections: Array<{
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
items: AttributeValue[] | undefined;
|
||||||
|
getLink: (v: AttributeValue) => string;
|
||||||
|
attrType?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
title: 'JA4 Fingerprints',
|
||||||
|
icon: '🔏',
|
||||||
|
items: attributes.ja4,
|
||||||
|
getLink: (v) => `/investigation/ja4/${encodeURIComponent(v.value)}`,
|
||||||
|
attrType: 'ja4',
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hosts ciblés',
|
||||||
|
icon: '🌐',
|
||||||
|
items: attributes.hosts,
|
||||||
|
getLink: (v) => `/detections/host/${encodeURIComponent(v.value)}`,
|
||||||
|
attrType: 'host',
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ASN',
|
||||||
|
icon: '🏢',
|
||||||
|
items: attributes.asns,
|
||||||
|
getLink: (v) => {
|
||||||
|
const n = v.value.match(/AS(\d+)/)?.[1] || v.value;
|
||||||
|
return `/detections/asn/${encodeURIComponent(n)}`;
|
||||||
|
},
|
||||||
|
attrType: 'asn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pays',
|
||||||
|
icon: '🌍',
|
||||||
|
items: attributes.countries,
|
||||||
|
getLink: (v) => `/detections/country/${encodeURIComponent(v.value)}`,
|
||||||
|
attrType: 'country',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Niveaux de menace',
|
||||||
|
icon: '⚠️',
|
||||||
|
items: attributes.threat_levels,
|
||||||
|
getLink: (v) => `/detections?threat_level=${encodeURIComponent(v.value)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2>
|
<h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
|
||||||
|
|
||||||
{/* JA4 Fingerprints */}
|
{/* User-Agents — plein format avec texte long */}
|
||||||
{attributes.ja4 && attributes.ja4.length > 0 && (
|
|
||||||
<AttributeSection
|
|
||||||
title="JA4 Fingerprints"
|
|
||||||
items={attributes.ja4}
|
|
||||||
getValue={(item) => item.value}
|
|
||||||
getLink={(item) => `/investigation/ja4/${encodeURIComponent(item.value)}`}
|
|
||||||
onViewAll={(value, count) => loadAssociatedIPs('ja4', value, count)}
|
|
||||||
showViewAll
|
|
||||||
viewAllLabel="Voir les IPs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User-Agents */}
|
|
||||||
{attributes.user_agents && attributes.user_agents.length > 0 && (
|
{attributes.user_agents && attributes.user_agents.length > 0 && (
|
||||||
<UASection items={attributes.user_agents} />
|
<UASection items={attributes.user_agents} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pays */}
|
{/* Grille 2 colonnes pour les autres attributs */}
|
||||||
{attributes.countries && attributes.countries.length > 0 && (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<AttributeSection
|
{sections.map((s) =>
|
||||||
title="Pays"
|
s.items && s.items.length > 0 ? (
|
||||||
items={attributes.countries}
|
<AttributeSection
|
||||||
getValue={(item) => item.value}
|
key={s.title}
|
||||||
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`}
|
title={s.title}
|
||||||
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)}
|
icon={s.icon}
|
||||||
showViewAll
|
items={s.items}
|
||||||
viewAllLabel="Voir les IPs"
|
getLink={s.getLink}
|
||||||
/>
|
attrType={s.attrType}
|
||||||
)}
|
mono={s.mono}
|
||||||
|
hideAssociatedIPs={hideAssociatedIPs}
|
||||||
|
onLoadIPs={loadAssociatedIPs}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ASN */}
|
{/* Modal IPs associées */}
|
||||||
{attributes.asns && attributes.asns.length > 0 && (
|
{(modal || loading) && (
|
||||||
<AttributeSection
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
title="ASN"
|
<div className="bg-background-secondary rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col shadow-2xl">
|
||||||
items={attributes.asns}
|
<div className="flex items-center justify-between px-6 py-4 border-b border-background-card">
|
||||||
getValue={(item) => item.value}
|
<h3 className="font-semibold text-text-primary">{modal?.title ?? 'Chargement…'}</h3>
|
||||||
getLink={(item) => {
|
<button onClick={() => setModal(null)} className="text-text-secondary hover:text-text-primary text-2xl leading-none">×</button>
|
||||||
const asnNumber = item.value.match(/AS(\d+)/)?.[1] || item.value;
|
|
||||||
return `/detections/asn/${encodeURIComponent(asnNumber)}`;
|
|
||||||
}}
|
|
||||||
onViewAll={(value, count) => loadAssociatedIPs('asn', value, count)}
|
|
||||||
showViewAll
|
|
||||||
viewAllLabel="Voir les IPs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hosts */}
|
|
||||||
{attributes.hosts && attributes.hosts.length > 0 && (
|
|
||||||
<AttributeSection
|
|
||||||
title="Hosts"
|
|
||||||
items={attributes.hosts}
|
|
||||||
getValue={(item) => item.value}
|
|
||||||
getLink={(item) => `/detections/host/${encodeURIComponent(item.value)}`}
|
|
||||||
onViewAll={(value, count) => loadAssociatedIPs('host', value, count)}
|
|
||||||
showViewAll
|
|
||||||
viewAllLabel="Voir les IPs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Threat Levels */}
|
|
||||||
{attributes.threat_levels && attributes.threat_levels.length > 0 && (
|
|
||||||
<AttributeSection
|
|
||||||
title="Niveaux de Menace"
|
|
||||||
items={attributes.threat_levels}
|
|
||||||
getValue={(item) => item.value}
|
|
||||||
getLink={(item) => `/detections?threat_level=${encodeURIComponent(item.value)}`}
|
|
||||||
onViewAll={(value, count) => loadAssociatedIPs('threat_level', value, count)}
|
|
||||||
showViewAll
|
|
||||||
viewAllLabel="Voir les IPs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal pour afficher la liste complète */}
|
|
||||||
{showModal && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-background-secondary rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-background-card">
|
|
||||||
<h3 className="text-xl font-semibold text-text-primary">{showModal.title}</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(null)}
|
|
||||||
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-text-secondary py-8">Chargement...</div>
|
<p className="text-center text-text-secondary py-8">Chargement…</p>
|
||||||
) : showModal.items.length > 0 ? (
|
) : modal && modal.items.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{showModal.items.map((item, index) => (
|
{modal.items.map((ip, i) => (
|
||||||
<div
|
<Link
|
||||||
key={index}
|
key={i}
|
||||||
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all"
|
to={`/detections/ip/${ip}`}
|
||||||
|
onClick={() => setModal(null)}
|
||||||
|
className="bg-background-card hover:bg-background-card/70 rounded px-3 py-2 font-mono text-sm text-accent-primary transition-colors"
|
||||||
>
|
>
|
||||||
{item}
|
{ip}
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{showModal.total > showModal.items.length && (
|
|
||||||
<p className="text-center text-text-secondary text-sm mt-4">
|
|
||||||
Affichage de {showModal.items.length} sur {showModal.total} éléments
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-text-secondary py-8">
|
<p className="text-center text-text-secondary py-8">Aucune donnée</p>
|
||||||
Aucune donnée disponible
|
)}
|
||||||
</div>
|
{modal && modal.total > modal.items.length && (
|
||||||
|
<p className="text-center text-text-secondary text-xs mt-4">
|
||||||
|
{modal.items.length} / {modal.total} affichées
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-background-card text-right">
|
||||||
{/* Footer */}
|
<button onClick={() => setModal(null)} className="bg-accent-primary hover:bg-accent-primary/80 text-white px-5 py-2 rounded-lg text-sm">Fermer</button>
|
||||||
<div className="p-4 border-t border-background-card text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(null)}
|
|
||||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-6 py-2 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Fermer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -169,159 +152,107 @@ export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composant UASection — jamais de troncature, expand/collapse
|
/* ─── AttributeSection ─────────────────────────────────────────────────────── */
|
||||||
function UASection({ items }: { items: AttributeValue[] }) {
|
function AttributeSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
getLink,
|
||||||
|
attrType,
|
||||||
|
mono,
|
||||||
|
hideAssociatedIPs,
|
||||||
|
onLoadIPs,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
items: AttributeValue[];
|
||||||
|
getLink: (v: AttributeValue) => string;
|
||||||
|
attrType?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
hideAssociatedIPs?: boolean;
|
||||||
|
onLoadIPs: (type: string, value: string, count: number) => void;
|
||||||
|
}) {
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const INITIAL = 5;
|
const LIMIT = 8;
|
||||||
const displayed = showAll ? items : items.slice(0, INITIAL);
|
const displayed = showAll ? items : items.slice(0, LIMIT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background-secondary rounded-lg p-6">
|
<div className="bg-background-secondary rounded-xl p-4">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||||
User-Agents ({items.length})
|
<span>{icon}</span> {title} <span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{displayed.map((item, index) => (
|
{displayed.map((item, i) => {
|
||||||
<div key={index} className="space-y-1">
|
const pct = item.percentage || 0;
|
||||||
<div className="flex items-start justify-between gap-4">
|
const barColor =
|
||||||
<div className="text-text-primary font-medium text-xs font-mono break-all leading-relaxed flex-1">
|
pct >= 50 ? 'bg-threat-critical' :
|
||||||
{item.value}
|
pct >= 25 ? 'bg-threat-high' :
|
||||||
|
pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link
|
||||||
|
to={getLink(item)}
|
||||||
|
className={`flex-1 text-xs hover:text-accent-primary transition-colors text-text-primary truncate ${mono ? 'font-mono' : ''}`}
|
||||||
|
title={item.value}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-text-secondary shrink-0">{item.count} ({pct.toFixed(0)}%)</span>
|
||||||
|
{!hideAssociatedIPs && attrType && (
|
||||||
|
<button
|
||||||
|
onClick={() => onLoadIPs(attrType, item.value, item.count)}
|
||||||
|
className="shrink-0 text-xs text-text-secondary hover:text-accent-primary transition-colors"
|
||||||
|
title="Voir les IPs associées"
|
||||||
|
>
|
||||||
|
👥
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||||
<div className="text-text-primary font-medium">{item.count}</div>
|
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-background-card rounded-full h-2">
|
);
|
||||||
<div
|
})}
|
||||||
className="h-2 rounded-full bg-threat-medium transition-all"
|
|
||||||
style={{ width: `${item.percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{items.length > INITIAL && (
|
{items.length > LIMIT && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAll(v => !v)}
|
onClick={() => setShowAll(v => !v)}
|
||||||
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
|
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
|
||||||
>
|
>
|
||||||
{showAll ? '↑ Réduire' : `↓ Voir les ${items.length - INITIAL} autres`}
|
{showAll ? '↑ Réduire' : `↓ ${items.length - LIMIT} de plus`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composant AttributeSection
|
/* ─── UASection ─────────────────────────────────────────────────────────────── */
|
||||||
function AttributeSection({
|
function UASection({ items }: { items: AttributeValue[] }) {
|
||||||
title,
|
|
||||||
items,
|
|
||||||
getValue,
|
|
||||||
getLink,
|
|
||||||
onViewAll,
|
|
||||||
showViewAll = false,
|
|
||||||
viewAllLabel = 'Voir les IPs',
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
items: AttributeValue[];
|
|
||||||
getValue: (item: AttributeValue) => string;
|
|
||||||
getLink: (item: AttributeValue) => string;
|
|
||||||
onViewAll?: (value: string, count: number) => void;
|
|
||||||
showViewAll?: boolean;
|
|
||||||
viewAllLabel?: string;
|
|
||||||
}) {
|
|
||||||
const displayItems = items.slice(0, 10);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background-secondary rounded-lg p-6">
|
<div className="bg-background-secondary rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||||
<h3 className="text-lg font-medium text-text-primary">
|
<span>🖥️</span> User-Agents
|
||||||
{title} ({items.length})
|
<span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
{showViewAll && items.length > 0 && (
|
|
||||||
<select
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value && onViewAll) {
|
|
||||||
const item = items.find(i => i.value === e.target.value);
|
|
||||||
if (item) {
|
|
||||||
onViewAll(item.value, item.count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
defaultValue=""
|
|
||||||
className="bg-background-card border border-background-card rounded-lg px-3 py-1 text-sm text-text-primary focus:outline-none focus:border-accent-primary"
|
|
||||||
>
|
|
||||||
<option value="">{viewAllLabel}...</option>
|
|
||||||
{displayItems.map((item, idx) => (
|
|
||||||
<option key={idx} value={item.value}>
|
|
||||||
{getValue(item).substring(0, 40)}{getValue(item).length > 40 ? '...' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{displayItems.map((item, index) => (
|
{items.map((item, i) => {
|
||||||
<AttributeRow
|
const pct = item.percentage || 0;
|
||||||
key={index}
|
return (
|
||||||
value={item}
|
<div key={i}>
|
||||||
getValue={getValue}
|
<div className="flex items-start gap-2 mb-1">
|
||||||
getLink={getLink}
|
<span className="flex-1 text-xs font-mono text-text-primary break-all leading-relaxed">{item.value}</span>
|
||||||
/>
|
<span className="shrink-0 text-xs text-text-secondary">{item.count} ({pct.toFixed(1)}%)</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||||
|
<div className="h-1.5 rounded-full bg-threat-medium" style={{ width: `${pct}%` }} />
|
||||||
{items.length > 10 && (
|
</div>
|
||||||
<p className="text-text-secondary text-sm mt-4 text-center">
|
</div>
|
||||||
... et {items.length - 10} autres (top 10 affiché)
|
);
|
||||||
</p>
|
})}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composant AttributeRow
|
|
||||||
function AttributeRow({
|
|
||||||
value,
|
|
||||||
getValue,
|
|
||||||
getLink,
|
|
||||||
}: {
|
|
||||||
value: AttributeValue;
|
|
||||||
getValue: (item: AttributeValue) => string;
|
|
||||||
getLink: (item: AttributeValue) => string;
|
|
||||||
}) {
|
|
||||||
const percentage = value.percentage || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
to={getLink(value)}
|
|
||||||
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1"
|
|
||||||
>
|
|
||||||
{getValue(value)}
|
|
||||||
</Link>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-text-primary font-medium">{value.count}</div>
|
|
||||||
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-background-card rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`}
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper pour la couleur de la barre
|
|
||||||
function getPercentageColor(percentage: number): string {
|
|
||||||
if (percentage >= 50) return 'bg-threat-critical';
|
|
||||||
if (percentage >= 25) return 'bg-threat-high';
|
|
||||||
if (percentage >= 10) return 'bg-threat-medium';
|
|
||||||
return 'bg-threat-low';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface DataTableProps<T> {
|
|||||||
defaultSortKey?: string;
|
defaultSortKey?: string;
|
||||||
defaultSortDir?: SortDir;
|
defaultSortDir?: SortDir;
|
||||||
onRowClick?: (row: T) => void;
|
onRowClick?: (row: T) => void;
|
||||||
|
onSort?: (key: string, dir: SortDir) => void;
|
||||||
rowKey: keyof T | ((row: T) => string);
|
rowKey: keyof T | ((row: T) => string);
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -31,6 +32,7 @@ export default function DataTable<T extends Record<string, any>>({
|
|||||||
defaultSortKey,
|
defaultSortKey,
|
||||||
defaultSortDir = 'desc',
|
defaultSortDir = 'desc',
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
onSort,
|
||||||
rowKey,
|
rowKey,
|
||||||
emptyMessage = 'Aucune donnée disponible',
|
emptyMessage = 'Aucune donnée disponible',
|
||||||
loading = false,
|
loading = false,
|
||||||
@ -82,7 +84,15 @@ export default function DataTable<T extends Record<string, any>>({
|
|||||||
alignClass(col.align),
|
alignClass(col.align),
|
||||||
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined}
|
onClick={isSortable ? () => {
|
||||||
|
handleSort(col.key as keyof T);
|
||||||
|
if (onSort) {
|
||||||
|
const newDir = String(sortKey) === col.key
|
||||||
|
? (sortDir === 'asc' ? 'desc' : 'asc')
|
||||||
|
: 'desc';
|
||||||
|
onSort(col.key, newDir);
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{col.label}
|
{col.label}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface UseDetectionsParams {
|
|||||||
search?: string;
|
search?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
|
group_by_ip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDetections(params: UseDetectionsParams = {}) {
|
export function useDetections(params: UseDetectionsParams = {}) {
|
||||||
@ -42,6 +43,7 @@ export function useDetections(params: UseDetectionsParams = {}) {
|
|||||||
params.search,
|
params.search,
|
||||||
params.sort_by,
|
params.sort_by,
|
||||||
params.sort_order,
|
params.sort_order,
|
||||||
|
params.group_by_ip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { data, loading, error };
|
return { data, loading, error };
|
||||||
|
|||||||
Reference in New Issue
Block a user