fix: correction de 7 bugs UI/API sur les dashboards avancés

BruteForce:
- Attaquants: strip ::ffff: des IPs (replaceRegexpAll dans SQL)
- Cibles: 'Voir détails' remplacé par expansion inline avec top IPs par host
  + nouveau endpoint GET /api/bruteforce/host/{host}/attackers
  + interface BruteForceTarget: top_ja4 → top_ja4s (cohérence avec API)

Header Fingerprint:
- Détail cluster: data.ips → data.items (clé API incorrecte)

Heatmap Temporelle:
- Top hosts ciblés: data.hosts → data.items (clé API incorrecte)
- Type annotation corrigé: { hosts: TopHost[] } → { items: TopHost[] }

Botnets Distribués:
- Clic sur ligne: data.countries → data.items (clé API incorrecte)

Rotation & Persistance:
- IPs rotateurs: strip ::ffff: (replaceRegexpAll dans SQL)
- IPs menaces persistantes: strip ::ffff: (replaceRegexpAll dans SQL)
- Historique JA4: data.history → data.ja4_history (clé API incorrecte)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-16 00:24:53 +01:00
parent 735d8b6101
commit 8032ebaab8
7 changed files with 158 additions and 48 deletions

View File

@ -55,7 +55,7 @@ async def get_bruteforce_attackers(limit: int = Query(50, ge=1, le=500)):
try:
sql = """
SELECT
src_ip AS ip,
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
uniq(host) AS distinct_hosts,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
@ -105,3 +105,37 @@ async def get_bruteforce_timeline():
return {"hours": hours}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/host/{host:path}/attackers")
async def get_host_attackers(host: str, limit: int = Query(20, ge=1, le=200)):
"""Top IPs attaquant un hôte spécifique, avec JA4 et type d'attaque."""
try:
sql = """
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
sum(hits) AS total_hits,
sum(query_params_count) AS total_params,
argMax(ja4, hits) AS ja4,
max(hits) AS max_hits_per_window
FROM mabase_prod.view_form_bruteforce_detected
WHERE host = %(host)s
GROUP BY src_ip
ORDER BY total_hits DESC
LIMIT %(limit)s
"""
result = db.query(sql, {"host": host, "limit": limit})
items = []
for row in result.result_rows:
total_hits = int(row[1])
total_params = int(row[2])
items.append({
"ip": str(row[0]),
"total_hits": total_hits,
"total_params":total_params,
"ja4": str(row[3] or ""),
"attack_type": "credential_stuffing" if total_hits > 0 and total_params / total_hits > 0.5 else "enumeration",
})
return {"host": host, "items": items, "total": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -14,7 +14,7 @@ async def get_ja4_rotators(limit: int = Query(50, ge=1, le=500)):
try:
sql = """
SELECT
src_ip AS ip,
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
distinct_ja4_count,
total_hits
FROM mabase_prod.view_host_ip_ja4_rotation
@ -42,7 +42,7 @@ async def get_persistent_threats(limit: int = Query(100, ge=1, le=1000)):
try:
sql = """
SELECT
src_ip AS ip,
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
recurrence,
worst_score,
worst_threat_level,

View File

@ -104,8 +104,8 @@ function BotnetRow({
try {
const res = await fetch(`/api/botnets/ja4/${encodeURIComponent(item.ja4)}/countries?limit=30`);
if (!res.ok) throw new Error('Erreur chargement des pays');
const data: { countries: CountryEntry[] } = await res.json();
setCountries(data.countries ?? []);
const data: { items: CountryEntry[] } = await res.json();
setCountries(data.items ?? []);
setCountriesLoaded(true);
} catch (err) {
setCountriesError(err instanceof Error ? err.message : 'Erreur inconnue');

View File

@ -9,7 +9,7 @@ interface BruteForceTarget {
total_hits: number;
total_params: number;
attack_type: string;
top_ja4: string[];
top_ja4s: string[];
}
interface BruteForceAttacker {
@ -63,6 +63,116 @@ function ErrorMessage({ message }: { message: string }) {
// ─── Main Component ───────────────────────────────────────────────────────────
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
function TargetRow({ t, navigate }: { t: BruteForceTarget; navigate: (path: string) => void }) {
const [expanded, setExpanded] = useState(false);
const [hostAttackers, setHostAttackers] = useState<HostAttacker[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const toggle = async () => {
setExpanded(prev => !prev);
if (!loaded && !expanded) {
setLoading(true);
try {
const res = await fetch(`/api/bruteforce/host/${encodeURIComponent(t.host)}/attackers?limit=20`);
if (!res.ok) throw new Error('Erreur chargement');
const data: { items: HostAttacker[] } = await res.json();
setHostAttackers(data.items ?? []);
setLoaded(true);
} catch { /* ignore */ }
finally { setLoading(false); }
}
};
return (
<>
<tr className="border-b border-border hover:bg-background-card transition-colors cursor-pointer" onClick={toggle}>
<td className="px-4 py-3 font-mono text-text-primary text-xs flex items-center gap-2">
<span className="text-accent-primary">{expanded ? '▾' : '▸'}</span>
{t.host}
</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.unique_ips)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_hits)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_params)}</td>
<td className="px-4 py-3">
{t.attack_type === 'credential_stuffing' ? (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-1 rounded-full">💳 Credential Stuffing</span>
) : (
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-1 rounded-full">🔍 Énumération</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(t.top_ja4s ?? []).slice(0, 2).map((ja4, i) => (
<span key={i} className="font-mono text-xs bg-background-card px-1.5 py-0.5 rounded border border-border text-text-secondary">
{ja4.slice(0, 12)}
</span>
))}
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-border bg-background-card">
<td colSpan={6} className="px-6 py-3">
{loading ? (
<div className="flex items-center gap-2 text-text-secondary text-sm py-2">
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
Chargement des attaquants
</div>
) : hostAttackers.length === 0 ? (
<p className="text-text-disabled text-sm py-2">Aucun attaquant trouvé.</p>
) : (
<div>
<p className="text-text-secondary text-xs mb-2 font-medium">
Top {hostAttackers.length} IP attaquant <span className="text-accent-primary font-mono">{t.host}</span>
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-text-disabled border-b border-border">
<th className="text-left py-1 pr-4">IP</th>
<th className="text-left py-1 pr-4">Hits</th>
<th className="text-left py-1 pr-4">Params</th>
<th className="text-left py-1 pr-4">JA4</th>
<th className="text-left py-1 pr-4">Type</th>
<th className="text-left py-1"></th>
</tr>
</thead>
<tbody>
{hostAttackers.map((a) => (
<tr key={a.ip} className="border-b border-border/50 hover:bg-background-secondary transition-colors">
<td className="py-1.5 pr-4 font-mono text-text-primary">{a.ip}</td>
<td className="py-1.5 pr-4 text-text-primary">{formatNumber(a.total_hits)}</td>
<td className="py-1.5 pr-4 text-text-secondary">{formatNumber(a.total_params)}</td>
<td className="py-1.5 pr-4 font-mono text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) + '…' : '—'}</td>
<td className="py-1.5 pr-4">
{a.attack_type === 'credential_stuffing'
? <span className="text-threat-critical">💳</span>
: <span className="text-threat-medium">🔍</span>
}
</td>
<td className="py-1.5">
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${a.ip}`); }}
className="text-accent-primary hover:underline text-xs"
>
Investiguer
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</td>
</tr>
)}
</>
);
}
export function BruteForceView() {
const navigate = useNavigate();
@ -202,51 +312,17 @@ export function BruteForceView() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">Host</th>
<th className="px-4 py-3">Host (cliquer pour détails)</th>
<th className="px-4 py-3">IPs distinctes</th>
<th className="px-4 py-3">Total hits</th>
<th className="px-4 py-3">Params combos</th>
<th className="px-4 py-3">Type d'attaque</th>
<th className="px-4 py-3">Top JA4</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.host} className="border-b border-border hover:bg-background-card transition-colors">
<td className="px-4 py-3 font-mono text-text-primary text-xs">{t.host}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.unique_ips)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_hits)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_params)}</td>
<td className="px-4 py-3">
{t.attack_type === 'credential_stuffing' ? (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-1 rounded-full">
💳 Credential Stuffing
</span>
) : (
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-1 rounded-full">
🔍 Énumération
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(t.top_ja4 ?? []).slice(0, 3).map((ja4) => (
<span key={ja4} className="font-mono text-xs bg-background-card px-1.5 py-0.5 rounded border border-border text-text-secondary">
{ja4.slice(0, 12)}…
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/investigation/${t.host}`)}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Voir détails
</button>
</td>
</tr>
<TargetRow key={t.host} t={t} navigate={navigate} />
))}
</tbody>
</table>

View File

@ -103,8 +103,8 @@ function ClusterRow({
try {
const res = await fetch(`/api/headers/cluster/${cluster.hash}/ips?limit=50`);
if (!res.ok) throw new Error('Erreur chargement IPs');
const data: { ips: ClusterIP[] } = await res.json();
setClusterIPs(data.ips ?? []);
const data: { items: ClusterIP[] } = await res.json();
setClusterIPs(data.items ?? []);
setIpsLoaded(true);
} catch (err) {
setIpsError(err instanceof Error ? err.message : 'Erreur inconnue');

View File

@ -116,8 +116,8 @@ export function HeatmapView() {
try {
const res = await fetch('/api/heatmap/top-hosts?limit=20');
if (!res.ok) throw new Error('Erreur chargement top hosts');
const data: { hosts: TopHost[] } = await res.json();
setTopHosts(data.hosts ?? []);
const data: { items: TopHost[] } = await res.json();
setTopHosts(data.items ?? []);
} catch (err) {
setTopHostsError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {

View File

@ -96,8 +96,8 @@ function RotatorRow({ item }: { item: JA4Rotator }) {
try {
const res = await fetch(`/api/rotation/ip/${encodeURIComponent(item.ip)}/ja4-history`);
if (!res.ok) throw new Error('Erreur chargement historique JA4');
const data: { history: JA4HistoryEntry[] } = await res.json();
setHistory(data.history ?? []);
const data: { ja4_history: JA4HistoryEntry[] } = await res.json();
setHistory(data.ja4_history ?? []);
setHistoryLoaded(true);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue');