feat(waf): Alerts — Regelübereinstimmungen in DB + UI — v1.2.77

- Migration 0038: waf_alerts-Tabelle
- AlertWriter (Buffered-Channel → async DB-Write)
- SPOE: MatchedRules → sendAlert() nach ProcessRequestHeaders()
- API: GET /waf/alerts + DELETE /waf/alerts
- WAF-Page: Tabs Domains | Alarme; Alarme-Tabelle mit Rule-ID,
  Severity, Aktion (Detected/Blocked), URI, Client-IP + Purge-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-03 10:40:43 +02:00
parent c83bb7b137
commit 220d9d7050
10 changed files with 453 additions and 37 deletions

View File

@@ -1800,6 +1800,30 @@
"customRulesHint": "Rohe SecRule-Direktiven die nach dem CRS eingefügt werden. Können CRS-Regeln überschreiben.",
"defaultHint": "Standard: Nur-Erkennung, Paranoia-Level 1. Erst auf Blocking wechseln, nachdem Alerts geprüft wurden.",
"saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden."
},
"tabs": {
"domains": "Domains",
"alerts": "Alarme"
},
"alerts": {
"total": "Einträge",
"empty": "Noch keine WAF-Alarme. Regelübereinstimmungen erscheinen hier.",
"purge30d": "> 30 Tage löschen",
"purgeConfirm": "Alle Alarme älter als 30 Tage löschen?",
"purged": "Alarme gelöscht.",
"blocked": "Geblockt",
"detected": "Erkannt",
"col": {
"time": "Zeit",
"action": "Aktion",
"hostname": "Domain",
"clientIp": "Client-IP",
"method": "Methode",
"uri": "URI",
"ruleId": "Regel-ID",
"severity": "Schwere",
"msg": "Meldung"
}
}
}
}

View File

@@ -1800,6 +1800,30 @@
"customRulesHint": "Raw SecRule directives appended after the CRS. Applied last, can override CRS rules.",
"defaultHint": "Default: Detection-Only, Paranoia Level 1. Switch to Blocking only after reviewing alerts.",
"saveFailed": "Failed to save WAF configuration."
},
"tabs": {
"domains": "Domains",
"alerts": "Alerts"
},
"alerts": {
"total": "entries",
"empty": "No WAF alerts yet. Rules matched will appear here.",
"purge30d": "Purge > 30 days",
"purgeConfirm": "Delete all alerts older than 30 days?",
"purged": "Alerts purged.",
"blocked": "Blocked",
"detected": "Detected",
"col": {
"time": "Time",
"action": "Action",
"hostname": "Domain",
"clientIp": "Client IP",
"method": "Method",
"uri": "URI",
"ruleId": "Rule ID",
"severity": "Severity",
"msg": "Message"
}
}
}
}

View File

@@ -1,11 +1,11 @@
import { useState } from 'react'
import {
Alert, Button, Card, Col, Drawer, Form, Input, Row,
Select, Space, Switch, Tag, Tooltip, Typography, message,
Alert, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row,
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import {
CheckCircleOutlined, CloseCircleOutlined,
SafetyCertificateOutlined, SettingOutlined,
CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined,
SafetyCertificateOutlined, SettingOutlined, WarningOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -230,6 +230,127 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
)
}
// ---------- Alerts tab ------------------------------------------------------
interface WafAlert {
id: number
domain_id?: number
hostname: string
client_ip: string
method: string
uri: string
rule_id: number
rule_msg: string
severity: string
action: 'detected' | 'blocked'
created_at: string
}
async function fetchAlerts(domainId?: number): Promise<WafAlert[]> {
const params = domainId ? `?domain_id=${domainId}&limit=500` : '?limit=500'
const r = await apiClient.get(`/waf/alerts${params}`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { alerts?: WafAlert[] }).alerts ?? []
}
function AlertsTab({ domainId }: { domainId?: number }) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: alerts, isLoading } = useQuery({
queryKey: ['waf', 'alerts', domainId ?? 'all'],
queryFn: () => fetchAlerts(domainId),
refetchInterval: 15_000,
})
const purge = useMutation({
mutationFn: () => apiClient.delete('/waf/alerts?days=30'),
onSuccess: () => {
message.success(t('waf.alerts.purged'))
void qc.invalidateQueries({ queryKey: ['waf', 'alerts'] })
},
})
const severityColor = (s: string) => {
switch (s?.toLowerCase()) {
case 'critical': return 'red'
case 'error': return 'red'
case 'warning': return 'orange'
case 'notice': return 'blue'
default: return 'default'
}
}
const columns = [
{
title: t('waf.alerts.col.time'),
dataIndex: 'created_at',
key: 'created_at',
width: 155,
render: (v: string) => (
<Text style={{ fontSize: 11, fontFamily: 'monospace' }}>
{new Date(v).toLocaleString()}
</Text>
),
},
{
title: t('waf.alerts.col.action'),
dataIndex: 'action',
key: 'action',
width: 100,
render: (v: string) => v === 'blocked'
? <Tag color="red"><CloseCircleOutlined /> {t('waf.alerts.blocked')}</Tag>
: <Tag color="blue"><WarningOutlined /> {t('waf.alerts.detected')}</Tag>,
},
{ title: t('waf.alerts.col.hostname'), dataIndex: 'hostname', key: 'hostname', width: 180,
render: (v: string) => <Text style={{ fontSize: 12 }}>{v}</Text> },
{ title: t('waf.alerts.col.clientIp'), dataIndex: 'client_ip', key: 'client_ip', width: 120,
render: (v: string) => <Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{v}</Text> },
{ title: t('waf.alerts.col.method'), dataIndex: 'method', key: 'method', width: 70 },
{ title: t('waf.alerts.col.uri'), dataIndex: 'uri', key: 'uri', ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{v}</Text> },
{ title: t('waf.alerts.col.ruleId'), dataIndex: 'rule_id', key: 'rule_id', width: 90,
render: (v: number) => <Tag style={{ fontFamily: 'monospace' }}>{v}</Tag> },
{ title: t('waf.alerts.col.severity'), dataIndex: 'severity', key: 'severity', width: 90,
render: (v: string) => <Tag color={severityColor(v)}>{v || '—'}</Tag> },
{ title: t('waf.alerts.col.msg'), dataIndex: 'rule_msg', key: 'rule_msg', ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11, color: '#64748B' }}>{v || '—'}</Text> },
]
return (
<div className="mt-2">
<div className="flex-between mb-12">
<Text type="secondary" style={{ fontSize: 12 }}>
{(alerts ?? []).length} {t('waf.alerts.total')}
</Text>
<Popconfirm
title={t('waf.alerts.purgeConfirm')}
onConfirm={() => purge.mutate()}
disabled={isViewer}
>
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} loading={purge.isPending}>
{t('waf.alerts.purge30d')}
</Button>
</Popconfirm>
</div>
{(alerts ?? []).length === 0 && !isLoading ? (
<Alert type="success" showIcon message={t('waf.alerts.empty')} />
) : (
<DataTable
rowKey="id"
loading={isLoading}
dataSource={alerts ?? []}
columns={columns}
rowClassName={(row: WafAlert) =>
row.action === 'blocked' ? 'fw-rule-row--zero-hit' : ''
}
/>
)}
</div>
)
}
// ---------- Page ------------------------------------------------------------
export default function WAFPage() {
@@ -381,22 +502,34 @@ export default function WAFPage() {
</Col>
</Row>
<Alert
type="info"
showIcon
className="mb-16"
message={t('waf.defaultOffHint')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={activeDomains}
columns={columns}
rowClassName={(row: Domain) => {
const cfg = configMap.get(row.id)
return cfg?.enabled ? '' : 'fw-rule-row--disabled'
}}
<Tabs
type="card"
items={[
{
key: 'domains',
label: t('waf.tabs.domains'),
children: (
<>
<Alert type="info" showIcon className="mb-16" message={t('waf.defaultOffHint')} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={activeDomains}
columns={columns}
rowClassName={(row: Domain) => {
const cfg = configMap.get(row.id)
return cfg?.enabled ? '' : 'fw-rule-row--disabled'
}}
/>
</>
),
},
{
key: 'alerts',
label: t('waf.tabs.alerts'),
children: <AlertsTab />,
},
]}
/>
<ConfigDrawer