feat(waf): Ausnahmen mit Notiz + Ausnahmen-Liste im Drawer — v1.2.79
- Migration 0039: exclusion_notes JSONB in waf_configs (rule_id → note) - Model/Service/Handler: exclusion_notes in Upsert + GET durchgereicht - Alerts-Tab: "Als Ausnahme"-Button öffnet Modal mit Notiz-Textarea; Notiz wird in exclusion_notes gespeichert - Config-Drawer: Ausnahmen als Liste (Rule-ID + Notiz + Entfernen-Button) statt rohem Textfeld; Ausnahmen nur noch via Alert-Tab hinzufügbar - i18n EN + DE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1799,7 +1799,13 @@
|
||||
"customRules": "Eigene SecRules",
|
||||
"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."
|
||||
"saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden.",
|
||||
"noExclusions": "Noch keine Regelausnahmen.",
|
||||
"noNote": "Keine Notiz",
|
||||
"exclusionsAddHint": "Ausnahmen über den Alarme-Tab hinzufügen — \"Als Ausnahme\" auf einem Alarm klicken.",
|
||||
"exceptionModalTitle": "Ausnahme für Regel {{rule}} hinzufügen",
|
||||
"exceptionModalHint": "Optional: Begründung warum diese Regel ein False Positive für diese Domain ist.",
|
||||
"exceptionNotePlaceholder": "z.B. Unsere API verwendet nicht-standardisierte Header die diese Regel auslösen."
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
|
||||
@@ -1799,7 +1799,13 @@
|
||||
"customRules": "Custom SecRules",
|
||||
"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."
|
||||
"saveFailed": "Failed to save WAF configuration.",
|
||||
"noExclusions": "No rule exclusions yet.",
|
||||
"noNote": "No note",
|
||||
"exclusionsAddHint": "Add exceptions via the Alerts tab — click \"Add exception\" on an alert.",
|
||||
"exceptionModalTitle": "Add exception for rule {{rule}}",
|
||||
"exceptionModalHint": "Optional: describe why this rule is a false positive for this domain.",
|
||||
"exceptionNotePlaceholder": "e.g. Our custom API uses non-standard headers that trigger this rule."
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row,
|
||||
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
|
||||
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import {
|
||||
@@ -32,6 +32,7 @@ interface WafConfig {
|
||||
mode: 'detection' | 'blocking'
|
||||
paranoia_level: number
|
||||
rule_exclusions: string[]
|
||||
exclusion_notes: Record<string, string>
|
||||
trusted_proxies: string[]
|
||||
custom_rules: string
|
||||
}
|
||||
@@ -63,6 +64,7 @@ function defaultConfig(domainId: number): WafConfig {
|
||||
mode: 'detection',
|
||||
paranoia_level: 1,
|
||||
rule_exclusions: [],
|
||||
exclusion_notes: {},
|
||||
trusted_proxies: [],
|
||||
custom_rules: '',
|
||||
}
|
||||
@@ -72,7 +74,6 @@ interface WafFormValues {
|
||||
enabled: boolean
|
||||
mode: 'detection' | 'blocking'
|
||||
paranoia_level: number
|
||||
rule_exclusions_str: string
|
||||
trusted_proxies_str: string
|
||||
custom_rules: string
|
||||
}
|
||||
@@ -134,12 +135,9 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
...cfg,
|
||||
rule_exclusions_str: (cfg.rule_exclusions ?? []).join(', '),
|
||||
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
||||
}}
|
||||
onFinish={(vals) => {
|
||||
const exclusions = (vals.rule_exclusions_str ?? '')
|
||||
.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
const proxies = (vals.trusted_proxies_str ?? '')
|
||||
.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
save.mutate({
|
||||
@@ -147,7 +145,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
enabled: vals.enabled,
|
||||
mode: vals.mode,
|
||||
paranoia_level: vals.paranoia_level,
|
||||
rule_exclusions: exclusions,
|
||||
rule_exclusions: cfg?.rule_exclusions ?? [],
|
||||
exclusion_notes: cfg?.exclusion_notes ?? {},
|
||||
trusted_proxies: proxies,
|
||||
custom_rules: vals.custom_rules ?? '',
|
||||
})
|
||||
@@ -190,15 +189,52 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('waf.config.exclusions')}
|
||||
name="rule_exclusions_str"
|
||||
help={t('waf.config.exclusionsHint')}
|
||||
>
|
||||
<Input
|
||||
disabled={isViewer}
|
||||
placeholder="920350, 941130"
|
||||
/>
|
||||
{/* 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,
|
||||
}}>
|
||||
<Tag style={{ fontFamily: 'monospace', flexShrink: 0 }}>{ruleId}</Tag>
|
||||
<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,
|
||||
exclusion_notes: newNotes,
|
||||
trusted_proxies: cfg?.trusted_proxies ?? [],
|
||||
custom_rules: cfg?.custom_rules ?? '',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{t('waf.config.exclusionsAddHint')}</Text>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -257,6 +293,8 @@ function AlertsTab({ domainId }: { domainId?: number }) {
|
||||
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'],
|
||||
@@ -273,19 +311,22 @@ function AlertsTab({ domainId }: { domainId?: number }) {
|
||||
})
|
||||
|
||||
const addException = useMutation({
|
||||
mutationFn: async ({ domainId, ruleId }: { domainId: number; ruleId: number }) => {
|
||||
// Fetch current config (returns default if none exists yet)
|
||||
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)
|
||||
return apiClient.put(`/waf/configs/${domainId}`, { ...cfg, rule_exclusions: exclusions })
|
||||
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')),
|
||||
@@ -341,19 +382,16 @@ function AlertsTab({ domainId }: { domainId?: number }) {
|
||||
width: 130,
|
||||
render: (_: unknown, row: WafAlert) => {
|
||||
const canExclude = !!row.domain_id && row.rule_id > 0 && !isViewer
|
||||
const isPending = addException.isPending &&
|
||||
addException.variables?.domainId === row.domain_id &&
|
||||
addException.variables?.ruleId === row.rule_id
|
||||
return (
|
||||
<Tooltip title={!row.domain_id ? t('waf.alerts.noDomain') : t('waf.alerts.addException')}>
|
||||
<Tooltip title={!row.domain_id ? t('waf.alerts.noDomain') : undefined}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canExclude}
|
||||
loading={isPending}
|
||||
onClick={() => row.domain_id && addException.mutate({
|
||||
domainId: row.domain_id,
|
||||
ruleId: row.rule_id,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!row.domain_id) return
|
||||
setExceptionModal({ domainId: row.domain_id, ruleId: row.rule_id })
|
||||
setExceptionNote('')
|
||||
}}
|
||||
>
|
||||
{t('waf.alerts.addException')}
|
||||
</Button>
|
||||
@@ -392,6 +430,31 @@ function AlertsTab({ domainId }: { domainId?: number }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user