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:
Debian
2026-06-03 11:31:49 +02:00
parent 801fa26da7
commit 8a4fefee73
8 changed files with 153 additions and 58 deletions

View File

@@ -1 +1 @@
1.2.78 1.2.79

View File

@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS exclusion_notes JSONB NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE waf_configs DROP COLUMN IF EXISTS exclusion_notes;

View File

@@ -73,12 +73,13 @@ func (h *WafHandler) Get(c *gin.Context) {
// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id. // upsertBody is the accepted JSON for PUT /waf/configs/:domain_id.
type upsertBody struct { type upsertBody struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Mode string `json:"mode"` Mode string `json:"mode"`
ParanoiaLevel int `json:"paranoia_level"` ParanoiaLevel int `json:"paranoia_level"`
RuleExclusions []string `json:"rule_exclusions"` RuleExclusions []string `json:"rule_exclusions"`
TrustedProxies []string `json:"trusted_proxies"` ExclusionNotes map[string]string `json:"exclusion_notes"`
CustomRules string `json:"custom_rules"` TrustedProxies []string `json:"trusted_proxies"`
CustomRules string `json:"custom_rules"`
} }
// Upsert creates or updates the WAF config for a domain. // Upsert creates or updates the WAF config for a domain.
@@ -106,12 +107,16 @@ func (h *WafHandler) Upsert(c *gin.Context) {
body.TrustedProxies = []string{} body.TrustedProxies = []string{}
} }
if body.ExclusionNotes == nil {
body.ExclusionNotes = map[string]string{}
}
cfg := models.WafConfig{ cfg := models.WafConfig{
DomainID: domainID, DomainID: domainID,
Enabled: body.Enabled, Enabled: body.Enabled,
Mode: body.Mode, Mode: body.Mode,
ParanoiaLevel: body.ParanoiaLevel, ParanoiaLevel: body.ParanoiaLevel,
RuleExclusions: body.RuleExclusions, RuleExclusions: body.RuleExclusions,
ExclusionNotes: body.ExclusionNotes,
TrustedProxies: body.TrustedProxies, TrustedProxies: body.TrustedProxies,
CustomRules: body.CustomRules, CustomRules: body.CustomRules,
} }
@@ -187,6 +192,7 @@ func defaultConfig(domainID int64) models.WafConfig {
Mode: "detection", Mode: "detection",
ParanoiaLevel: 1, ParanoiaLevel: 1,
RuleExclusions: []string{}, RuleExclusions: []string{},
ExclusionNotes: map[string]string{},
TrustedProxies: []string{}, TrustedProxies: []string{},
CustomRules: "", CustomRules: "",
} }

View File

@@ -5,15 +5,16 @@ import "time"
// WafConfig holds the per-domain WAF policy. // WafConfig holds the per-domain WAF policy.
// Default on creation: enabled=false, mode=detection, paranoia_level=1. // Default on creation: enabled=false, mode=detection, paranoia_level=1.
type WafConfig struct { type WafConfig struct {
ID int64 `gorm:"primaryKey" json:"id"` ID int64 `gorm:"primaryKey" json:"id"`
DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"` DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"`
Enabled bool `gorm:"column:enabled" json:"enabled"` Enabled bool `gorm:"column:enabled" json:"enabled"`
Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking" Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking"
ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 14 ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 14
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"` RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"` ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"` TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
} }
func (WafConfig) TableName() string { return "waf_configs" } func (WafConfig) TableName() string { return "waf_configs" }

View File

@@ -22,7 +22,7 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = ` const baseSelect = `
SELECT id, domain_id, enabled, mode, paranoia_level, SELECT id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, trusted_proxies, custom_rules, updated_at rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
FROM waf_configs FROM waf_configs
` `
@@ -30,11 +30,14 @@ func scan(row pgx.Row) (*models.WafConfig, error) {
var c models.WafConfig var c models.WafConfig
err := row.Scan( err := row.Scan(
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, &c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
return &c, nil return &c, nil
} }
@@ -73,24 +76,28 @@ func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConf
// Returns the resulting row. // Returns the resulting row.
func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) { func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) {
c.UpdatedAt = time.Now() c.UpdatedAt = time.Now()
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
row := r.Pool.QueryRow(ctx, ` row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_configs INSERT INTO waf_configs
(domain_id, enabled, mode, paranoia_level, (domain_id, enabled, mode, paranoia_level,
rule_exclusions, trusted_proxies, custom_rules, updated_at) rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (domain_id) DO UPDATE SET ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled, enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode, mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level, paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions, rule_exclusions = EXCLUDED.rule_exclusions,
trusted_proxies = EXCLUDED.trusted_proxies, exclusion_notes = EXCLUDED.exclusion_notes,
custom_rules = EXCLUDED.custom_rules, trusted_proxies = EXCLUDED.trusted_proxies,
updated_at = EXCLUDED.updated_at custom_rules = EXCLUDED.custom_rules,
updated_at = EXCLUDED.updated_at
RETURNING id, domain_id, enabled, mode, paranoia_level, RETURNING id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, trusted_proxies, custom_rules, updated_at rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
`, `,
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel, c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
c.RuleExclusions, c.TrustedProxies, c.CustomRules, c.UpdatedAt, c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
) )
return scan(row) return scan(row)
} }

View File

@@ -1799,7 +1799,13 @@
"customRules": "Eigene SecRules", "customRules": "Eigene SecRules",
"customRulesHint": "Rohe SecRule-Direktiven die nach dem CRS eingefügt werden. Können CRS-Regeln überschreiben.", "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.", "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": { "tabs": {
"domains": "Domains", "domains": "Domains",

View File

@@ -1799,7 +1799,13 @@
"customRules": "Custom SecRules", "customRules": "Custom SecRules",
"customRulesHint": "Raw SecRule directives appended after the CRS. Applied last, can override CRS rules.", "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.", "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": { "tabs": {
"domains": "Domains", "domains": "Domains",

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { 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, Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd' } from 'antd'
import { import {
@@ -32,6 +32,7 @@ interface WafConfig {
mode: 'detection' | 'blocking' mode: 'detection' | 'blocking'
paranoia_level: number paranoia_level: number
rule_exclusions: string[] rule_exclusions: string[]
exclusion_notes: Record<string, string>
trusted_proxies: string[] trusted_proxies: string[]
custom_rules: string custom_rules: string
} }
@@ -63,6 +64,7 @@ function defaultConfig(domainId: number): WafConfig {
mode: 'detection', mode: 'detection',
paranoia_level: 1, paranoia_level: 1,
rule_exclusions: [], rule_exclusions: [],
exclusion_notes: {},
trusted_proxies: [], trusted_proxies: [],
custom_rules: '', custom_rules: '',
} }
@@ -72,7 +74,6 @@ interface WafFormValues {
enabled: boolean enabled: boolean
mode: 'detection' | 'blocking' mode: 'detection' | 'blocking'
paranoia_level: number paranoia_level: number
rule_exclusions_str: string
trusted_proxies_str: string trusted_proxies_str: string
custom_rules: string custom_rules: string
} }
@@ -134,12 +135,9 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
layout="vertical" layout="vertical"
initialValues={{ initialValues={{
...cfg, ...cfg,
rule_exclusions_str: (cfg.rule_exclusions ?? []).join(', '),
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '), trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
}} }}
onFinish={(vals) => { onFinish={(vals) => {
const exclusions = (vals.rule_exclusions_str ?? '')
.split(',').map((s: string) => s.trim()).filter(Boolean)
const proxies = (vals.trusted_proxies_str ?? '') const proxies = (vals.trusted_proxies_str ?? '')
.split(',').map((s: string) => s.trim()).filter(Boolean) .split(',').map((s: string) => s.trim()).filter(Boolean)
save.mutate({ save.mutate({
@@ -147,7 +145,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
enabled: vals.enabled, enabled: vals.enabled,
mode: vals.mode, mode: vals.mode,
paranoia_level: vals.paranoia_level, paranoia_level: vals.paranoia_level,
rule_exclusions: exclusions, rule_exclusions: cfg?.rule_exclusions ?? [],
exclusion_notes: cfg?.exclusion_notes ?? {},
trusted_proxies: proxies, trusted_proxies: proxies,
custom_rules: vals.custom_rules ?? '', custom_rules: vals.custom_rules ?? '',
}) })
@@ -190,15 +189,52 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item {/* Exclusions list — shows existing exclusions with notes + remove button */}
label={t('waf.config.exclusions')} <Form.Item label={t('waf.config.exclusions')}>
name="rule_exclusions_str" {(cfg?.rule_exclusions ?? []).length === 0 ? (
help={t('waf.config.exclusionsHint')} <Text type="secondary" style={{ fontSize: 12 }}>{t('waf.config.noExclusions')}</Text>
> ) : (
<Input <Space direction="vertical" style={{ width: '100%' }} size={4}>
disabled={isViewer} {(cfg?.rule_exclusions ?? []).map(ruleId => (
placeholder="920350, 941130" <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>
<Form.Item <Form.Item
@@ -257,6 +293,8 @@ function AlertsTab({ domainId }: { domainId?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' 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({ const { data: alerts, isLoading } = useQuery({
queryKey: ['waf', 'alerts', domainId ?? 'all'], queryKey: ['waf', 'alerts', domainId ?? 'all'],
@@ -273,19 +311,22 @@ function AlertsTab({ domainId }: { domainId?: number }) {
}) })
const addException = useMutation({ const addException = useMutation({
mutationFn: async ({ domainId, ruleId }: { domainId: number; ruleId: number }) => { mutationFn: async ({ domainId, ruleId, note }: { domainId: number; ruleId: number; note: string }) => {
// Fetch current config (returns default if none exists yet)
const r = await apiClient.get(`/waf/configs/${domainId}`) const r = await apiClient.get(`/waf/configs/${domainId}`)
const cfg: WafConfig = isEnvelope(r.data) const cfg: WafConfig = isEnvelope(r.data)
? (r.data.data as { config: WafConfig }).config ? (r.data.data as { config: WafConfig }).config
: defaultConfig(domainId) : defaultConfig(domainId)
const exclusions = [...(cfg.rule_exclusions ?? [])] const exclusions = [...(cfg.rule_exclusions ?? [])]
const notes = { ...(cfg.exclusion_notes ?? {}) }
const ruleStr = String(ruleId) const ruleStr = String(ruleId)
if (!exclusions.includes(ruleStr)) exclusions.push(ruleStr) 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: () => { onSuccess: () => {
message.success(t('waf.alerts.exceptionAdded')) message.success(t('waf.alerts.exceptionAdded'))
setExceptionModal(null)
setExceptionNote('')
void qc.invalidateQueries({ queryKey: ['waf'] }) void qc.invalidateQueries({ queryKey: ['waf'] })
}, },
onError: () => message.error(t('waf.alerts.exceptionFailed')), onError: () => message.error(t('waf.alerts.exceptionFailed')),
@@ -341,19 +382,16 @@ function AlertsTab({ domainId }: { domainId?: number }) {
width: 130, width: 130,
render: (_: unknown, row: WafAlert) => { render: (_: unknown, row: WafAlert) => {
const canExclude = !!row.domain_id && row.rule_id > 0 && !isViewer 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 ( 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 <Button
size="small" size="small"
disabled={!canExclude} disabled={!canExclude}
loading={isPending} onClick={() => {
onClick={() => row.domain_id && addException.mutate({ if (!row.domain_id) return
domainId: row.domain_id, setExceptionModal({ domainId: row.domain_id, ruleId: row.rule_id })
ruleId: row.rule_id, setExceptionNote('')
})} }}
> >
{t('waf.alerts.addException')} {t('waf.alerts.addException')}
</Button> </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> </div>
) )
} }