diff --git a/VERSION b/VERSION index 1152f77..d968182 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.78 +1.2.79 diff --git a/internal/database/migrations/0039_waf_exclusion_notes.sql b/internal/database/migrations/0039_waf_exclusion_notes.sql new file mode 100644 index 0000000..b8caa0f --- /dev/null +++ b/internal/database/migrations/0039_waf_exclusion_notes.sql @@ -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; diff --git a/internal/handlers/waf.go b/internal/handlers/waf.go index 53825db..0ae1a2b 100644 --- a/internal/handlers/waf.go +++ b/internal/handlers/waf.go @@ -73,12 +73,13 @@ func (h *WafHandler) Get(c *gin.Context) { // upsertBody is the accepted JSON for PUT /waf/configs/:domain_id. type upsertBody struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` - ParanoiaLevel int `json:"paranoia_level"` - RuleExclusions []string `json:"rule_exclusions"` - TrustedProxies []string `json:"trusted_proxies"` - CustomRules string `json:"custom_rules"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + ParanoiaLevel int `json:"paranoia_level"` + RuleExclusions []string `json:"rule_exclusions"` + ExclusionNotes map[string]string `json:"exclusion_notes"` + TrustedProxies []string `json:"trusted_proxies"` + CustomRules string `json:"custom_rules"` } // Upsert creates or updates the WAF config for a domain. @@ -106,12 +107,16 @@ func (h *WafHandler) Upsert(c *gin.Context) { body.TrustedProxies = []string{} } + if body.ExclusionNotes == nil { + body.ExclusionNotes = map[string]string{} + } cfg := models.WafConfig{ DomainID: domainID, Enabled: body.Enabled, Mode: body.Mode, ParanoiaLevel: body.ParanoiaLevel, RuleExclusions: body.RuleExclusions, + ExclusionNotes: body.ExclusionNotes, TrustedProxies: body.TrustedProxies, CustomRules: body.CustomRules, } @@ -187,6 +192,7 @@ func defaultConfig(domainID int64) models.WafConfig { Mode: "detection", ParanoiaLevel: 1, RuleExclusions: []string{}, + ExclusionNotes: map[string]string{}, TrustedProxies: []string{}, CustomRules: "", } diff --git a/internal/models/waf.go b/internal/models/waf.go index 467340b..918d9c0 100644 --- a/internal/models/waf.go +++ b/internal/models/waf.go @@ -5,15 +5,16 @@ import "time" // WafConfig holds the per-domain WAF policy. // Default on creation: enabled=false, mode=detection, paranoia_level=1. type WafConfig struct { - ID int64 `gorm:"primaryKey" json:"id"` - DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"` - Enabled bool `gorm:"column:enabled" json:"enabled"` - Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking" - ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 1–4 - RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"` - TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"` - CustomRules string `gorm:"column:custom_rules" json:"custom_rules"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` + ID int64 `gorm:"primaryKey" json:"id"` + DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"` + Enabled bool `gorm:"column:enabled" json:"enabled"` + Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking" + ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 1–4 + RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"` + ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note + TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"` + 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" } diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go index e4df01e..c229139 100644 --- a/internal/services/waf/waf.go +++ b/internal/services/waf/waf.go @@ -22,7 +22,7 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } const baseSelect = ` 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 ` @@ -30,11 +30,14 @@ func scan(row pgx.Row) (*models.WafConfig, error) { var c models.WafConfig err := row.Scan( &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 { return nil, err } + if c.ExclusionNotes == nil { + c.ExclusionNotes = map[string]string{} + } return &c, nil } @@ -73,24 +76,28 @@ func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConf // Returns the resulting row. func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) { c.UpdatedAt = time.Now() + if c.ExclusionNotes == nil { + c.ExclusionNotes = map[string]string{} + } row := r.Pool.QueryRow(ctx, ` INSERT INTO waf_configs (domain_id, enabled, mode, paranoia_level, - rule_exclusions, trusted_proxies, custom_rules, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (domain_id) DO UPDATE SET - enabled = EXCLUDED.enabled, - mode = EXCLUDED.mode, - paranoia_level = EXCLUDED.paranoia_level, - rule_exclusions = EXCLUDED.rule_exclusions, - trusted_proxies = EXCLUDED.trusted_proxies, - custom_rules = EXCLUDED.custom_rules, - updated_at = EXCLUDED.updated_at + enabled = EXCLUDED.enabled, + mode = EXCLUDED.mode, + paranoia_level = EXCLUDED.paranoia_level, + rule_exclusions = EXCLUDED.rule_exclusions, + exclusion_notes = EXCLUDED.exclusion_notes, + trusted_proxies = EXCLUDED.trusted_proxies, + custom_rules = EXCLUDED.custom_rules, + updated_at = EXCLUDED.updated_at 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.RuleExclusions, c.TrustedProxies, c.CustomRules, c.UpdatedAt, + c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt, ) return scan(row) } diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 54275b7..30fadb7 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ad80897..9959fc1 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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", diff --git a/management-ui/src/pages/WAF/index.tsx b/management-ui/src/pages/WAF/index.tsx index 8820ae6..fa0f7d3 100644 --- a/management-ui/src/pages/WAF/index.tsx +++ b/management-ui/src/pages/WAF/index.tsx @@ -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 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) { - - + {/* Exclusions list — shows existing exclusions with notes + remove button */} + + {(cfg?.rule_exclusions ?? []).length === 0 ? ( + {t('waf.config.noExclusions')} + ) : ( + + {(cfg?.rule_exclusions ?? []).map(ruleId => ( +
+ {ruleId} + + {cfg?.exclusion_notes?.[ruleId] || {t('waf.config.noNote')}} + + {!isViewer && ( +
+ ))} +
+ )} +
+ {t('waf.config.exclusionsAddHint')} +
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 ( - + @@ -392,6 +430,31 @@ function AlertsTab({ domainId }: { domainId?: number }) { } /> )} + + {/* Exception note modal */} + { setExceptionModal(null); setExceptionNote('') }} + onOk={() => exceptionModal && addException.mutate({ + domainId: exceptionModal.domainId, + ruleId: exceptionModal.ruleId, + note: exceptionNote, + })} + confirmLoading={addException.isPending} + okText={t('waf.alerts.addException')} + > +

+ {t('waf.alerts.exceptionModalHint')} +

+ setExceptionNote(e.target.value)} + autoFocus + /> +
) }