Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme- Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar, Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher). - Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) + waf_configs.app_profiles. - Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate). - Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei Profil-Edit. - UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im Domain-Drawer. FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents — selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt damit erstmals tatsächlich. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1019 lines
35 KiB
TypeScript
1019 lines
35 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
|
|
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
|
|
} from 'antd'
|
|
import {
|
|
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
|
|
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
|
|
WarningOutlined,
|
|
} from '@ant-design/icons'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
import PageHeader from '../../components/PageHeader'
|
|
import DataTable from '../../components/DataTable'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
import { CRS_RULES, getRuleDescription } from './crsRules'
|
|
|
|
const { Text } = Typography
|
|
|
|
// ---------- Types -----------------------------------------------------------
|
|
|
|
interface Domain {
|
|
id: number
|
|
name: string
|
|
active: boolean
|
|
}
|
|
|
|
interface WafConfig {
|
|
id?: number
|
|
domain_id: number
|
|
enabled: boolean
|
|
mode: 'detection' | 'blocking'
|
|
paranoia_level: number
|
|
rule_exclusions: string[]
|
|
crs_plugins: string[]
|
|
app_profiles: string[]
|
|
exclusion_notes: Record<string, string>
|
|
trusted_proxies: string[]
|
|
custom_rules: string
|
|
}
|
|
|
|
interface WafProfile {
|
|
id: number
|
|
name: string
|
|
description: string
|
|
rule_exclusions: string[]
|
|
builtin: boolean
|
|
created_at?: string
|
|
updated_at?: string
|
|
}
|
|
|
|
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
|
|
const CRS_PLUGIN_OPTIONS = [
|
|
{ value: 'nextcloud', label: 'Nextcloud' },
|
|
{ value: 'wordpress', label: 'WordPress' },
|
|
{ value: 'drupal', label: 'Drupal' },
|
|
]
|
|
|
|
// ---------- API helpers -----------------------------------------------------
|
|
|
|
async function fetchDomains(): Promise<Domain[]> {
|
|
const r = await apiClient.get('/domains')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { domains?: Domain[] }).domains ?? []
|
|
}
|
|
|
|
async function fetchWafConfigs(): Promise<WafConfig[]> {
|
|
const r = await apiClient.get('/waf/configs')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { configs?: WafConfig[] }).configs ?? []
|
|
}
|
|
|
|
async function fetchWafConfig(domainId: number): Promise<WafConfig> {
|
|
const r = await apiClient.get(`/waf/configs/${domainId}`)
|
|
if (isEnvelope(r.data)) return (r.data.data as { config: WafConfig }).config
|
|
return defaultConfig(domainId)
|
|
}
|
|
|
|
function defaultConfig(domainId: number): WafConfig {
|
|
return {
|
|
domain_id: domainId,
|
|
enabled: false,
|
|
mode: 'detection',
|
|
paranoia_level: 1,
|
|
rule_exclusions: [],
|
|
crs_plugins: [],
|
|
app_profiles: [],
|
|
exclusion_notes: {},
|
|
trusted_proxies: [],
|
|
custom_rules: '',
|
|
}
|
|
}
|
|
|
|
async function fetchProfiles(): Promise<WafProfile[]> {
|
|
const r = await apiClient.get('/waf/profiles')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
|
|
}
|
|
|
|
interface WafFormValues {
|
|
enabled: boolean
|
|
mode: 'detection' | 'blocking'
|
|
paranoia_level: number
|
|
crs_plugins: string[]
|
|
app_profiles: string[]
|
|
trusted_proxies_str: string
|
|
custom_rules: string
|
|
}
|
|
|
|
// ---------- Config Drawer ---------------------------------------------------
|
|
|
|
interface ConfigDrawerProps {
|
|
domainName: string
|
|
domainId: number | null
|
|
onClose: () => void
|
|
}
|
|
|
|
function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const [form] = Form.useForm<WafFormValues>()
|
|
const [addRuleId, setAddRuleId] = useState<string>('')
|
|
const [addNote, setAddNote] = useState<string>('')
|
|
// Durchsuchbare Optionen aus der CRS-Regel-Liste (ID — Beschreibung).
|
|
// filterOption unten matcht sowohl ID als auch Beschreibung.
|
|
const ruleOptions = useMemo(
|
|
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })),
|
|
[],
|
|
)
|
|
|
|
const { data: cfg, isLoading } = useQuery({
|
|
queryKey: ['waf', 'config', domainId],
|
|
queryFn: () => fetchWafConfig(domainId!),
|
|
enabled: domainId !== null,
|
|
})
|
|
|
|
// Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen.
|
|
const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
|
|
const profileOptions = useMemo(
|
|
() => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })),
|
|
[profiles],
|
|
)
|
|
|
|
const save = useMutation({
|
|
mutationFn: (values: WafConfig) =>
|
|
apiClient.put(`/waf/configs/${domainId}`, values),
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
|
onClose()
|
|
},
|
|
onError: () => message.error(t('waf.config.saveFailed')),
|
|
})
|
|
|
|
return (
|
|
<Drawer
|
|
title={<><SafetyCertificateOutlined style={{ color: '#0EA5E9', marginRight: 8 }} />{domainName}</>}
|
|
open={domainId !== null}
|
|
onClose={onClose}
|
|
width={520}
|
|
footer={
|
|
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
|
|
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
|
<Button
|
|
type="primary"
|
|
loading={save.isPending}
|
|
disabled={isViewer}
|
|
onClick={() => form.submit()}
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
{!isLoading && cfg && (
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={{
|
|
...cfg,
|
|
app_profiles: cfg.app_profiles ?? [],
|
|
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
|
}}
|
|
onFinish={(vals) => {
|
|
const proxies = (vals.trusted_proxies_str ?? '')
|
|
.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
save.mutate({
|
|
domain_id: domainId!,
|
|
enabled: vals.enabled,
|
|
mode: vals.mode,
|
|
paranoia_level: vals.paranoia_level,
|
|
rule_exclusions: cfg?.rule_exclusions ?? [],
|
|
crs_plugins: vals.crs_plugins ?? [],
|
|
app_profiles: vals.app_profiles ?? [],
|
|
exclusion_notes: cfg?.exclusion_notes ?? {},
|
|
trusted_proxies: proxies,
|
|
custom_rules: vals.custom_rules ?? '',
|
|
})
|
|
}}
|
|
>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
className="mb-16"
|
|
message={t('waf.config.defaultHint')}
|
|
/>
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item label={t('waf.config.enabled')} name="enabled" valuePropName="checked">
|
|
<Switch disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item label={t('waf.config.mode')} name="mode">
|
|
<Select disabled={isViewer}>
|
|
<Select.Option value="detection">
|
|
<Tag color="blue">{t('waf.mode.detection')}</Tag>
|
|
</Select.Option>
|
|
<Select.Option value="blocking">
|
|
<Tag color="red">{t('waf.mode.blocking')}</Tag>
|
|
</Select.Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Form.Item label={t('waf.config.paranoia')} name="paranoia_level">
|
|
<Select disabled={isViewer}>
|
|
{[1, 2, 3, 4].map(pl => (
|
|
<Select.Option key={pl} value={pl}>
|
|
PL{pl} — {t(`waf.pl.${pl}`)}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label={t('waf.config.crsPlugins')}
|
|
name="crs_plugins"
|
|
help={t('waf.config.crsPluginsHint')}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
allowClear
|
|
disabled={isViewer}
|
|
placeholder={t('waf.config.crsPluginsPlaceholder')}
|
|
options={CRS_PLUGIN_OPTIONS}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label={t('waf.config.appProfiles')}
|
|
name="app_profiles"
|
|
help={t('waf.config.appProfilesHint')}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
allowClear
|
|
disabled={isViewer}
|
|
placeholder={t('waf.config.appProfilesPlaceholder')}
|
|
options={profileOptions}
|
|
notFoundContent={t('waf.profiles.empty')}
|
|
/>
|
|
</Form.Item>
|
|
|
|
{/* Exclusions list — shows existing exclusions with notes + remove button */}
|
|
<Form.Item label={t('waf.config.exclusions')}>
|
|
{(cfg?.rule_exclusions ?? []).length === 0 ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('waf.config.noExclusions')}</Text>
|
|
) : (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
|
{(cfg?.rule_exclusions ?? []).map(ruleId => (
|
|
<div key={ruleId} style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 8,
|
|
padding: '6px 10px', background: '#F8FAFC',
|
|
border: '1px solid #E2E8F0', borderRadius: 6,
|
|
}}>
|
|
<Tooltip title={getRuleDescription(Number(ruleId))} placement="topLeft">
|
|
<Tag style={{ fontFamily: 'monospace', flexShrink: 0, cursor: 'help' }}>{ruleId}</Tag>
|
|
</Tooltip>
|
|
<Text style={{ fontSize: 12, color: '#475569', flex: 1 }}>
|
|
{cfg?.exclusion_notes?.[ruleId] || <em style={{ color: '#94A3B8' }}>{t('waf.config.noNote')}</em>}
|
|
</Text>
|
|
{!isViewer && (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
onClick={() => {
|
|
const newExclusions = (cfg?.rule_exclusions ?? []).filter(r => r !== ruleId)
|
|
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
|
|
delete newNotes[ruleId]
|
|
save.mutate({
|
|
domain_id: domainId!,
|
|
enabled: cfg?.enabled ?? false,
|
|
mode: cfg?.mode ?? 'detection',
|
|
paranoia_level: cfg?.paranoia_level ?? 1,
|
|
rule_exclusions: newExclusions,
|
|
crs_plugins: cfg?.crs_plugins ?? [],
|
|
app_profiles: cfg?.app_profiles ?? [],
|
|
exclusion_notes: newNotes,
|
|
trusted_proxies: cfg?.trusted_proxies ?? [],
|
|
custom_rules: cfg?.custom_rules ?? '',
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</Space>
|
|
)}
|
|
{!isViewer && (
|
|
<div style={{ marginTop: 10 }}>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<Select
|
|
showSearch
|
|
value={addRuleId || undefined}
|
|
placeholder={t('waf.config.exclusionAddPlaceholder')}
|
|
style={{ flex: 1, minWidth: 0 }}
|
|
options={ruleOptions}
|
|
optionFilterProp="label"
|
|
notFoundContent={t('waf.config.exclusionAddNotFound')}
|
|
onChange={(v) => setAddRuleId(v)}
|
|
/>
|
|
<Input
|
|
placeholder={t('waf.config.exclusionAddNote')}
|
|
value={addNote}
|
|
onChange={(e) => setAddNote(e.target.value)}
|
|
style={{ width: 150 }}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
loading={save.isPending}
|
|
disabled={!addRuleId}
|
|
onClick={() => {
|
|
if (!addRuleId) return
|
|
const existing = cfg?.rule_exclusions ?? []
|
|
if (existing.includes(addRuleId)) { setAddRuleId(''); setAddNote(''); return }
|
|
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
|
|
if (addNote.trim()) newNotes[addRuleId] = addNote.trim()
|
|
save.mutate({
|
|
domain_id: domainId!,
|
|
enabled: cfg?.enabled ?? false,
|
|
mode: cfg?.mode ?? 'detection',
|
|
paranoia_level: cfg?.paranoia_level ?? 1,
|
|
rule_exclusions: [...existing, addRuleId],
|
|
crs_plugins: cfg?.crs_plugins ?? [],
|
|
app_profiles: cfg?.app_profiles ?? [],
|
|
exclusion_notes: newNotes,
|
|
trusted_proxies: cfg?.trusted_proxies ?? [],
|
|
custom_rules: cfg?.custom_rules ?? '',
|
|
})
|
|
setAddRuleId(''); setAddNote('')
|
|
}}
|
|
>
|
|
{t('waf.config.exclusionAddBtn')}
|
|
</Button>
|
|
</div>
|
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
|
|
{t('waf.config.exclusionsAddHint')}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label={t('waf.config.trustedProxies')}
|
|
name="trusted_proxies_str"
|
|
help={t('waf.config.trustedProxiesHint')}
|
|
>
|
|
<Input
|
|
disabled={isViewer}
|
|
placeholder="10.0.0.1, 192.168.1.0/24"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label={t('waf.config.customRules')}
|
|
name="custom_rules"
|
|
help={t('waf.config.customRulesHint')}
|
|
>
|
|
<Input.TextArea
|
|
disabled={isViewer}
|
|
rows={5}
|
|
placeholder={'SecRule REQUEST_URI "@contains /api/" \\\n "id:9000001,phase:1,pass,nolog"'}
|
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
)}
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
// ---------- 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, configMap }: { domainId?: number; configMap: Map<number, WafConfig> }) {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const [exceptionModal, setExceptionModal] = useState<{ domainId: number; ruleId: number } | null>(null)
|
|
const [exceptionNote, setExceptionNote] = useState('')
|
|
|
|
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 addException = useMutation({
|
|
mutationFn: async ({ domainId, ruleId, note }: { domainId: number; ruleId: number; note: string }) => {
|
|
const r = await apiClient.get(`/waf/configs/${domainId}`)
|
|
const cfg: WafConfig = isEnvelope(r.data)
|
|
? (r.data.data as { config: WafConfig }).config
|
|
: defaultConfig(domainId)
|
|
const exclusions = [...(cfg.rule_exclusions ?? [])]
|
|
const notes = { ...(cfg.exclusion_notes ?? {}) }
|
|
const ruleStr = String(ruleId)
|
|
if (!exclusions.includes(ruleStr)) exclusions.push(ruleStr)
|
|
if (note.trim()) notes[ruleStr] = note.trim()
|
|
return apiClient.put(`/waf/configs/${domainId}`, { ...cfg, rule_exclusions: exclusions, exclusion_notes: notes })
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('waf.alerts.exceptionAdded'))
|
|
setExceptionModal(null)
|
|
setExceptionNote('')
|
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
|
},
|
|
onError: () => message.error(t('waf.alerts.exceptionFailed')),
|
|
})
|
|
|
|
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) => (
|
|
<Tooltip title={getRuleDescription(v)} placement="topLeft">
|
|
<Tag style={{ fontFamily: 'monospace', cursor: 'help' }}>{v}</Tag>
|
|
</Tooltip>
|
|
) },
|
|
{ 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> },
|
|
{
|
|
title: '',
|
|
key: 'exception',
|
|
width: 130,
|
|
render: (_: unknown, row: WafAlert) => {
|
|
const isExcluded = !!row.domain_id &&
|
|
(configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id))
|
|
if (isExcluded) {
|
|
return (
|
|
<Tag color="success" icon={<CheckCircleOutlined />}>
|
|
{t('waf.alerts.alreadyExcluded')}
|
|
</Tag>
|
|
)
|
|
}
|
|
const canExclude = !!row.domain_id && row.rule_id > 0 && !isViewer
|
|
return (
|
|
<Tooltip title={!row.domain_id ? t('waf.alerts.noDomain') : undefined}>
|
|
<Button
|
|
size="small"
|
|
disabled={!canExclude}
|
|
onClick={() => {
|
|
if (!row.domain_id) return
|
|
setExceptionModal({ domainId: row.domain_id, ruleId: row.rule_id })
|
|
setExceptionNote('')
|
|
}}
|
|
>
|
|
{t('waf.alerts.addException')}
|
|
</Button>
|
|
</Tooltip>
|
|
)
|
|
},
|
|
},
|
|
]
|
|
|
|
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) => {
|
|
const isExcluded = !!row.domain_id &&
|
|
(configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id))
|
|
if (isExcluded) return 'waf-alert-row--excluded'
|
|
if (row.action === 'blocked') return 'fw-rule-row--zero-hit'
|
|
return ''
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Exception note modal */}
|
|
<Modal
|
|
title={t('waf.alerts.exceptionModalTitle', { rule: exceptionModal?.ruleId })}
|
|
open={exceptionModal !== null}
|
|
onCancel={() => { setExceptionModal(null); setExceptionNote('') }}
|
|
onOk={() => exceptionModal && addException.mutate({
|
|
domainId: exceptionModal.domainId,
|
|
ruleId: exceptionModal.ruleId,
|
|
note: exceptionNote,
|
|
})}
|
|
confirmLoading={addException.isPending}
|
|
okText={t('waf.alerts.addException')}
|
|
>
|
|
<p style={{ marginBottom: 12, color: '#475569', fontSize: 13 }}>
|
|
{t('waf.alerts.exceptionModalHint')}
|
|
</p>
|
|
<Input.TextArea
|
|
rows={3}
|
|
placeholder={t('waf.alerts.exceptionNotePlaceholder')}
|
|
value={exceptionNote}
|
|
onChange={e => setExceptionNote(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Profiles tab ----------------------------------------------------
|
|
|
|
interface ProfileFormValues {
|
|
name: string
|
|
description: string
|
|
rule_exclusions: string[]
|
|
}
|
|
|
|
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const [form] = Form.useForm<ProfileFormValues>()
|
|
const isCreate = profile !== null && profile.id === 0
|
|
const ruleOptions = useMemo(
|
|
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })),
|
|
[],
|
|
)
|
|
|
|
// Formular bei jedem Öffnen/Wechsel neu befüllen.
|
|
useEffect(() => {
|
|
if (profile) {
|
|
form.setFieldsValue({
|
|
name: profile.name,
|
|
description: profile.description,
|
|
rule_exclusions: profile.rule_exclusions ?? [],
|
|
})
|
|
}
|
|
}, [profile, form])
|
|
|
|
const save = useMutation({
|
|
mutationFn: (vals: ProfileFormValues) =>
|
|
isCreate
|
|
? apiClient.post('/waf/profiles', vals)
|
|
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
|
|
onSuccess: () => {
|
|
message.success(t('waf.profiles.saved'))
|
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
|
onClose()
|
|
},
|
|
onError: () => message.error(t('waf.profiles.saveFailed')),
|
|
})
|
|
|
|
return (
|
|
<Drawer
|
|
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
|
|
open={profile !== null}
|
|
onClose={onClose}
|
|
width={520}
|
|
footer={
|
|
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
|
|
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
|
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={(vals) => save.mutate({
|
|
name: vals.name,
|
|
description: vals.description ?? '',
|
|
rule_exclusions: vals.rule_exclusions ?? [],
|
|
})}
|
|
>
|
|
<Form.Item
|
|
label={t('waf.profiles.name')}
|
|
name="name"
|
|
rules={[{ required: true, max: 60 }]}
|
|
>
|
|
<Input placeholder={t('waf.profiles.namePlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('waf.profiles.description')} name="description">
|
|
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('waf.profiles.rules')}
|
|
name="rule_exclusions"
|
|
help={t('waf.profiles.rulesHint')}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
showSearch
|
|
allowClear
|
|
placeholder={t('waf.profiles.rulesPlaceholder')}
|
|
options={ruleOptions}
|
|
optionFilterProp="label"
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
function ProfilesTab() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const [editing, setEditing] = useState<WafProfile | null>(null)
|
|
|
|
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
|
|
const custom = (profiles ?? []).filter(p => !p.builtin)
|
|
|
|
const del = useMutation({
|
|
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
|
|
onSuccess: () => {
|
|
message.success(t('waf.profiles.deleted'))
|
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
|
},
|
|
onError: () => message.error(t('waf.profiles.deleteFailed')),
|
|
})
|
|
|
|
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
|
|
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
|
|
id: 0,
|
|
name: seed?.name ?? '',
|
|
description: seed?.description ?? '',
|
|
rule_exclusions: seed?.rule_exclusions ?? [],
|
|
builtin: false,
|
|
})
|
|
|
|
const columns = [
|
|
{
|
|
title: t('waf.profiles.col.name'),
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
|
|
},
|
|
{
|
|
title: t('waf.profiles.col.description'),
|
|
dataIndex: 'description',
|
|
key: 'description',
|
|
ellipsis: true,
|
|
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
|
|
},
|
|
{
|
|
title: t('waf.profiles.col.rules'),
|
|
key: 'rules',
|
|
width: 90,
|
|
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
width: 210,
|
|
render: (_: unknown, row: WafProfile) => (
|
|
<Space size={4}>
|
|
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
|
|
{t('waf.profiles.edit')}
|
|
</Button>
|
|
<Tooltip title={t('waf.profiles.clone')}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
disabled={isViewer}
|
|
onClick={() => openCreate({
|
|
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
|
|
description: row.description,
|
|
rule_exclusions: row.rule_exclusions,
|
|
})}
|
|
/>
|
|
</Tooltip>
|
|
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
|
|
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
className="mb-16"
|
|
message={t('waf.profiles.intro')}
|
|
description={
|
|
<div style={{ marginTop: 8 }}>
|
|
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
{CRS_PLUGIN_OPTIONS.map(p => (
|
|
<Space key={p.value} size={2}>
|
|
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
|
|
{!isViewer && (
|
|
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
|
|
{t('waf.profiles.asTemplate')}
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
))}
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="flex-between mb-12">
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{custom.length} {t('waf.profiles.typeCustom')}
|
|
</Text>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
|
|
{t('waf.profiles.new')}
|
|
</Button>
|
|
</div>
|
|
{custom.length === 0 && !isLoading ? (
|
|
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
|
|
) : (
|
|
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
|
|
)}
|
|
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Page ------------------------------------------------------------
|
|
|
|
export default function WAFPage() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const [drawerDomain, setDrawerDomain] = useState<{ id: number; name: string } | null>(null)
|
|
|
|
const { data: domains } = useQuery({
|
|
queryKey: ['domains'],
|
|
queryFn: fetchDomains,
|
|
})
|
|
const { data: wafConfigs, isLoading } = useQuery({
|
|
queryKey: ['waf', 'configs'],
|
|
queryFn: fetchWafConfigs,
|
|
refetchInterval: 30_000,
|
|
})
|
|
|
|
const quickToggle = useMutation({
|
|
mutationFn: async ({ domainId, enabled }: { domainId: number; enabled: boolean }) => {
|
|
const existing = wafConfigs?.find(c => c.domain_id === domainId) ?? defaultConfig(domainId)
|
|
return apiClient.put(`/waf/configs/${domainId}`, { ...existing, enabled })
|
|
},
|
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ['waf'] }),
|
|
onError: () => message.error(t('waf.toggleFailed')),
|
|
})
|
|
|
|
const configMap = new Map<number, WafConfig>(
|
|
(wafConfigs ?? []).map(c => [c.domain_id, c])
|
|
)
|
|
|
|
const activeDomains = (domains ?? []).filter(d => d.active)
|
|
const enabledCount = (wafConfigs ?? []).filter(c => c.enabled).length
|
|
|
|
const columns = [
|
|
{
|
|
title: t('waf.col.domain'),
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
render: (name: string) => (
|
|
<Text strong style={{ fontSize: 13 }}>{name}</Text>
|
|
),
|
|
},
|
|
{
|
|
title: t('waf.col.status'),
|
|
key: 'status',
|
|
width: 80,
|
|
render: (_: unknown, row: Domain) => {
|
|
const cfg = configMap.get(row.id)
|
|
const enabled = cfg?.enabled ?? false
|
|
return (
|
|
<Switch
|
|
size="small"
|
|
checked={enabled}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.domainId === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ domainId: row.id, enabled: checked })}
|
|
/>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: t('waf.col.mode'),
|
|
key: 'mode',
|
|
width: 130,
|
|
render: (_: unknown, row: Domain) => {
|
|
const cfg = configMap.get(row.id)
|
|
if (!cfg?.enabled) return <Text type="secondary">—</Text>
|
|
return cfg.mode === 'blocking'
|
|
? <Tag color="red"><CloseCircleOutlined /> {t('waf.mode.blocking')}</Tag>
|
|
: <Tag color="blue"><CheckCircleOutlined /> {t('waf.mode.detection')}</Tag>
|
|
},
|
|
},
|
|
{
|
|
title: t('waf.col.paranoia'),
|
|
key: 'paranoia',
|
|
width: 90,
|
|
render: (_: unknown, row: Domain) => {
|
|
const cfg = configMap.get(row.id)
|
|
if (!cfg?.enabled) return <Text type="secondary">—</Text>
|
|
return <Tag>PL{cfg.paranoia_level}</Tag>
|
|
},
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
width: 110,
|
|
render: (_: unknown, row: Domain) => (
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('waf.configure')}>
|
|
<Button
|
|
size="small"
|
|
icon={<SettingOutlined />}
|
|
disabled={isViewer}
|
|
onClick={() => setDrawerDomain({ id: row.id, name: row.name })}
|
|
>
|
|
{t('waf.configure')}
|
|
</Button>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
icon={<SafetyCertificateOutlined />}
|
|
title={t('waf.title')}
|
|
subtitle={t('waf.intro')}
|
|
/>
|
|
|
|
{/* Status strip */}
|
|
<Row gutter={[12, 12]} className="mb-16">
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Card size="small">
|
|
<Space direction="vertical" size={0}>
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
{t('waf.stat.protected')}
|
|
</Text>
|
|
<div style={{ fontSize: 22, fontWeight: 600, color: enabledCount > 0 ? '#10B981' : '#94A3B8' }}>
|
|
{enabledCount}
|
|
<span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {activeDomains.length}</span>
|
|
</div>
|
|
</Space>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Card size="small">
|
|
<Space direction="vertical" size={0}>
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
{t('waf.stat.blocking')}
|
|
</Text>
|
|
<div style={{ fontSize: 22, fontWeight: 600, color: '#EF4444' }}>
|
|
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'blocking').length}
|
|
</div>
|
|
</Space>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Card size="small">
|
|
<Space direction="vertical" size={0}>
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
{t('waf.stat.detection')}
|
|
</Text>
|
|
<div style={{ fontSize: 22, fontWeight: 600, color: '#0EA5E9' }}>
|
|
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'detection').length}
|
|
</div>
|
|
</Space>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<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: 'profiles',
|
|
label: t('waf.tabs.profiles'),
|
|
children: <ProfilesTab />,
|
|
},
|
|
{
|
|
key: 'alerts',
|
|
label: t('waf.tabs.alerts'),
|
|
children: <AlertsTab configMap={configMap} />,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<ConfigDrawer
|
|
domainName={drawerDomain?.name ?? ''}
|
|
domainId={drawerDomain?.id ?? null}
|
|
onClose={() => setDrawerDomain(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|