fix: IPs IPv4 avec notation ::ffff: corrigée

🐛 PROBLÈME:
• ClickHouse stocke les IPv4 en IPv6 (::ffff:x.x.x.x)
• Les requêtes SQL utilisaient toString() → '::ffff:1.2.3.4'
• Impossible de naviguer vers /entities/ip/::ffff:1.2.3.4

 SOLUTION:
• Utilisation de IPv4NumToString(toIPv4(src_ip))
• Convertit ::ffff:x.x.x.x → x.x.x.x
• Filtre isIPv4MappedIPv6() pour les IPv4 uniquement

BACKEND:
• Requête SQL mise à jour avec IPv4NumToString()
• sample_ip retourne maintenant 'x.x.x.x' (propre)
• subnet retourne 'x.x.x.0/24' (propre)

FRONTEND:
• Suppression cleanIP() et getSampleIP() (inutiles)
• Utilisation directe: cluster.sample_ip || cluster.subnet?.split('/')[0]
• Tous les boutons utilisent la même logique

RÉSULTAT:
• Avant: /entities/ip/::ffff:176.65.132.0 
• Après: /entities/ip/176.65.132.1 

 Build: SUCCESS
 Container: restarted

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
SOC Analyst
2026-03-14 22:53:00 +01:00
parent 1e0e5d211d
commit df0fe1387b
2 changed files with 14 additions and 37 deletions

View File

@ -10,14 +10,6 @@ from ..models import BaseModel
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
# Nettoyer une adresse IP (enlever ::ffff: prefix)
def cleanIP(address: str) -> str:
if not address:
return ''
import re
return re.sub(r'^::ffff:', '', address, flags=re.IGNORECASE)
@router.get("/clusters")
async def get_incident_clusters(
hours: int = Query(24, ge=1, le=168, description="Fenêtre temporelle en heures"),
@ -34,13 +26,15 @@ async def get_incident_clusters(
"""
try:
# Cluster par subnet /24 avec une IP exemple
# Note: src_ip est en IPv6, les IPv4 sont stockés comme ::ffff:x.x.x.x
# toIPv4() convertit les IPv4-mapped, IPv4NumToString() retourne l'IPv4 en notation x.x.x.x
cluster_query = """
WITH subnet_groups AS (
SELECT
concat(
splitByChar('.', toString(src_ip))[1], '.',
splitByChar('.', toString(src_ip))[2], '.',
splitByChar('.', toString(src_ip))[3], '.0/24'
splitByChar('.', IPv4NumToString(toIPv4(src_ip)))[1], '.',
splitByChar('.', IPv4NumToString(toIPv4(src_ip)))[2], '.',
splitByChar('.', IPv4NumToString(toIPv4(src_ip)))[3], '.0/24'
) AS subnet,
count() AS total_detections,
uniq(src_ip) AS unique_ips,
@ -51,9 +45,10 @@ async def get_incident_clusters(
argMax(asn_number, detected_at) AS asn_number,
argMax(threat_level, detected_at) AS threat_level,
avg(anomaly_score) AS avg_score,
any(src_ip) AS sample_ip
any(IPv4NumToString(toIPv4(src_ip))) AS sample_ip
FROM ml_detected_anomalies
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
AND isIPv4MappedIPv6(src_ip) -- Filtre uniquement les IPv4
GROUP BY subnet
HAVING total_detections >= 2
)
@ -115,7 +110,7 @@ async def get_incident_clusters(
"total_detections": row[1],
"unique_ips": row[2],
"subnet": row[0],
"sample_ip": cleanIP(row[10]) if row[10] else None,
"sample_ip": row[10] if row[10] else row[0].split('/')[0],
"ja4": row[5] or "",
"primary_ua": "python-requests",
"primary_target": "Unknown",

View File

@ -107,24 +107,6 @@ export function IncidentsView() {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
// Nettoyer une adresse IP (enlever ::ffff: prefix)
const cleanIP = (address: string): string => {
if (!address) return '';
return address.replace(/^::ffff:/i, '');
};
// Générer une IP exemple depuis un subnet
const getSampleIP = (subnet: string): string => {
const clean = cleanIP(subnet);
const ipParts = clean.replace('/24', '').split('.');
if (ipParts.length === 4) {
// Remplacer le dernier octet par 1
ipParts[3] = '1';
return ipParts.join('.');
}
return cleanIP(subnet.split('/')[0]);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@ -268,7 +250,7 @@ export function IncidentsView() {
</span>
<span className="text-lg font-bold text-text-primary">{cluster.id}</span>
<span className="text-text-secondary">|</span>
<span className="font-mono text-sm text-text-primary">{cleanIP(cluster.subnet || '')}</span>
<span className="font-mono text-sm text-text-primary">{cluster.subnet || ''}</span>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
@ -322,13 +304,13 @@ export function IncidentsView() {
<div className="flex gap-2">
<button
onClick={() => navigate(`/investigation/${cleanIP(cluster.sample_ip || getSampleIP(cluster.subnet || ''))}`)}
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
className="px-3 py-1.5 bg-accent-primary text-white rounded text-sm hover:bg-accent-primary/80 transition-colors"
>
Investiguer
</button>
<button
onClick={() => navigate(`/entities/ip/${cleanIP(cluster.sample_ip || getSampleIP(cluster.subnet || ''))}`)}
onClick={() => navigate(`/entities/ip/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Voir détails
@ -336,7 +318,7 @@ export function IncidentsView() {
<button
onClick={() => {
// Quick classify
navigate(`/bulk-classify?ips=${encodeURIComponent(cleanIP(cluster.sample_ip || getSampleIP(cluster.subnet || '')))}`);
navigate(`/bulk-classify?ips=${encodeURIComponent(cluster.sample_ip || cluster.subnet?.split('/')[0] || '')}`);
}}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
@ -351,7 +333,7 @@ export function IncidentsView() {
objects: [{
type: 'indicator',
id: `indicator--${cluster.id}`,
pattern: `[ipv4-addr:value = '${cleanIP(cluster.subnet?.split('/')[0] || '')}'`,
pattern: `[ipv4-addr:value = '${cluster.subnet?.split('/')[0] || ''}'`,
pattern_type: 'stix'
}]
};
@ -415,7 +397,7 @@ export function IncidentsView() {
>
<td className="px-4 py-3 text-text-secondary">{index + 1}</td>
<td className="px-4 py-3 font-mono text-sm text-text-primary">
{cleanIP(cluster.subnet?.split('/')[0] || 'Unknown')}
{cluster.subnet?.split('/')[0] || 'Unknown'}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">IP</td>
<td className="px-4 py-3">