diff --git a/VERSION b/VERSION index 9ce730c..3f7543c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.76 +1.2.77 diff --git a/cmd/edgeguard-waf/main.go b/cmd/edgeguard-waf/main.go index cc4ce61..f3b291f 100644 --- a/cmd/edgeguard-waf/main.go +++ b/cmd/edgeguard-waf/main.go @@ -70,9 +70,12 @@ func main() { } }() + alertWriter := intwaf.NewAlertWriter(pool, 512) + agent := intwaf.SPOEAgent{ - Manager: mgr, - Addr: spoeAddr, + Manager: mgr, + AlertWriter: alertWriter, + Addr: spoeAddr, } slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir) diff --git a/internal/database/migrations/0038_waf_alerts.sql b/internal/database/migrations/0038_waf_alerts.sql new file mode 100644 index 0000000..bccad46 --- /dev/null +++ b/internal/database/migrations/0038_waf_alerts.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS waf_alerts ( + id BIGSERIAL PRIMARY KEY, + domain_id BIGINT REFERENCES domains(id) ON DELETE CASCADE, + hostname TEXT NOT NULL, + client_ip TEXT NOT NULL, + method TEXT NOT NULL, + uri TEXT NOT NULL, + rule_id INT NOT NULL DEFAULT 0, + rule_msg TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, -- 'detected' | 'blocked' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS waf_alerts_domain_created ON waf_alerts(domain_id, created_at DESC); +CREATE INDEX IF NOT EXISTS waf_alerts_created ON waf_alerts(created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS waf_alerts; diff --git a/internal/handlers/waf.go b/internal/handlers/waf.go index f4e5a29..53825db 100644 --- a/internal/handlers/waf.go +++ b/internal/handlers/waf.go @@ -36,6 +36,8 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) { g.GET("/configs", h.List) g.GET("/configs/:domain_id", h.Get) g.PUT("/configs/:domain_id", h.Upsert) + g.GET("/alerts", h.ListAlerts) + g.DELETE("/alerts", h.PurgeAlerts) } // List returns all WAF configs. @@ -134,6 +136,48 @@ func (h *WafHandler) Upsert(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"config": result}) } +// ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N +func (h *WafHandler) ListAlerts(c *gin.Context) { + var domainID *int64 + if v := c.Query("domain_id"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid domain_id")) + return + } + domainID = &id + } + limit := 200 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"alerts": alerts}) +} + +// PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30) +func (h *WafHandler) PurgeAlerts(c *gin.Context) { + days := 30 + if v := c.Query("days"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + days = n + } + } + if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge", + "", gin.H{"days": days}, h.NodeID) + response.OK(c, gin.H{"ok": true, "days": days}) +} + // defaultConfig returns a sensible disabled default for a domain // that has no WAF config row yet. func defaultConfig(domainID int64) models.WafConfig { diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go index f98629a..e4df01e 100644 --- a/internal/services/waf/waf.go +++ b/internal/services/waf/waf.go @@ -113,6 +113,73 @@ func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) { return out, rows.Err() } +// WafAlert mirrors the waf_alerts DB row. +type WafAlert struct { + ID int64 `json:"id"` + DomainID *int64 `json:"domain_id,omitempty"` + Hostname string `json:"hostname"` + ClientIP string `json:"client_ip"` + Method string `json:"method"` + URI string `json:"uri"` + RuleID int `json:"rule_id"` + RuleMsg string `json:"rule_msg"` + Severity string `json:"severity"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` +} + +// ListAlerts returns recent WAF alerts, optionally filtered by domain_id. +func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]WafAlert, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error } + var err error + if domainID != nil { + rows2, e := r.Pool.Query(ctx, ` + SELECT id, domain_id, hostname, client_ip, method, uri, + rule_id, rule_msg, severity, action, created_at + FROM waf_alerts + WHERE domain_id = $1 + ORDER BY created_at DESC LIMIT $2 + `, *domainID, limit) + rows, err = rows2, e + } else { + rows2, e := r.Pool.Query(ctx, ` + SELECT id, domain_id, hostname, client_ip, method, uri, + rule_id, rule_msg, severity, action, created_at + FROM waf_alerts + ORDER BY created_at DESC LIMIT $1 + `, limit) + rows, err = rows2, e + } + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]WafAlert, 0, limit) + for rows.Next() { + var a WafAlert + if err := rows.Scan( + &a.ID, &a.DomainID, &a.Hostname, &a.ClientIP, &a.Method, &a.URI, + &a.RuleID, &a.RuleMsg, &a.Severity, &a.Action, &a.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// PurgeAlerts removes alerts older than the given number of days. +func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error { + _, err := r.Pool.Exec(ctx, + `DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`, + olderThanDays, + ) + return err +} + // DomainConfigPair combines a domain hostname with its WAF config. type DomainConfigPair struct { Hostname string diff --git a/internal/waf/alerts.go b/internal/waf/alerts.go new file mode 100644 index 0000000..227bf01 --- /dev/null +++ b/internal/waf/alerts.go @@ -0,0 +1,84 @@ +package waf + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Alert represents a single WAF rule match that was logged. +type Alert struct { + ID int64 `json:"id"` + DomainID *int64 `json:"domain_id,omitempty"` + Hostname string `json:"hostname"` + ClientIP string `json:"client_ip"` + Method string `json:"method"` + URI string `json:"uri"` + RuleID int `json:"rule_id"` + RuleMsg string `json:"rule_msg"` + Severity string `json:"severity"` + Action string `json:"action"` // "detected" | "blocked" + CreatedAt time.Time `json:"created_at"` +} + +// AlertWriter accepts Alert values via a buffered channel and writes +// them to PostgreSQL asynchronously so SPOE handling stays low-latency. +type AlertWriter struct { + pool *pgxpool.Pool + ch chan Alert +} + +// NewAlertWriter creates an AlertWriter and starts its background goroutine. +// bufSize is the number of unwritten alerts that can queue before drops. +func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter { + aw := &AlertWriter{ + pool: pool, + ch: make(chan Alert, bufSize), + } + go aw.run() + return aw +} + +// Send enqueues an alert. Drops silently if the channel is full to +// avoid slowing down SPOE request handling. +func (aw *AlertWriter) Send(a Alert) { + select { + case aw.ch <- a: + default: + slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID) + } +} + +func (aw *AlertWriter) run() { + for a := range aw.ch { + aw.write(a) + } +} + +func (aw *AlertWriter) write(a Alert) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Resolve domain_id from hostname (best-effort). + var domainID *int64 + var id int64 + if err := aw.pool.QueryRow(ctx, + `SELECT id FROM domains WHERE name = $1 AND active = true LIMIT 1`, + a.Hostname, + ).Scan(&id); err == nil { + domainID = &id + } + + if _, err := aw.pool.Exec(ctx, ` + INSERT INTO waf_alerts + (domain_id, hostname, client_ip, method, uri, + rule_id, rule_msg, severity, action) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + `, domainID, a.Hostname, a.ClientIP, a.Method, a.URI, + a.RuleID, a.RuleMsg, a.Severity, a.Action, + ); err != nil { + slog.Warn("waf: write alert to db failed", "error", err) + } +} diff --git a/internal/waf/spoe.go b/internal/waf/spoe.go index 04accea..5ddbb75 100644 --- a/internal/waf/spoe.go +++ b/internal/waf/spoe.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/corazawaf/coraza/v3/types" "github.com/dropmorepackets/haproxy-go/pkg/encoding" "github.com/dropmorepackets/haproxy-go/spop" ) @@ -13,8 +14,9 @@ import ( // SPOEAgent wraps the haproxy-go SPOE server and dispatches each // inspected request to the appropriate per-domain Coraza engine. type SPOEAgent struct { - Manager *Manager - Addr string + Manager *Manager + AlertWriter *AlertWriter + Addr string } // ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled. @@ -106,33 +108,48 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc // Evaluate request headers. interruption := tx.ProcessRequestHeaders() + + // Log all matched rules (detection + blocking). + for _, mr := range tx.MatchedRules() { + a.sendAlert(host, clientIP, method, uri, mr, interruption != nil) + } + if interruption != nil { status := interruption.Status if status == 0 { status = http.StatusForbidden } slog.Info("waf: request blocked", - "host", host, - "method", method, - "uri", uri, - "client", clientIP, - "status", status, - "mode", de.Mode, + "host", host, "method", method, "uri", uri, + "client", clientIP, "status", status, "rule", interruption.RuleID, ) if de.Mode == "blocking" { if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil { slog.Warn("waf: SetInt64 status", "error", err) } } + } +} + +// sendAlert enqueues a WAF alert for async DB write. +func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.MatchedRule, blocked bool) { + if a.AlertWriter == nil { return } - - // Alert-only log for detection mode. - if tx.IsInterrupted() && de.Mode != "blocking" { - slog.Info("waf: request flagged (detection)", - "host", host, "method", method, "uri", uri, "client", clientIP, - ) + action := "detected" + if blocked && mr.Disruptive() { + action = "blocked" } + a.AlertWriter.Send(Alert{ + Hostname: host, + ClientIP: clientIP, + Method: method, + URI: uri, + RuleID: mr.Rule().ID(), + RuleMsg: mr.Message(), + Severity: mr.Rule().Severity().String(), + Action: action, + }) } // parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 4e83ed0..1576cee 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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" + } } } } \ No newline at end of file diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 08ad49e..533e44f 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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" + } } } } \ No newline at end of file diff --git a/management-ui/src/pages/WAF/index.tsx b/management-ui/src/pages/WAF/index.tsx index 662eda2..90f0c78 100644 --- a/management-ui/src/pages/WAF/index.tsx +++ b/management-ui/src/pages/WAF/index.tsx @@ -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 { + 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) => ( + + {new Date(v).toLocaleString()} + + ), + }, + { + title: t('waf.alerts.col.action'), + dataIndex: 'action', + key: 'action', + width: 100, + render: (v: string) => v === 'blocked' + ? {t('waf.alerts.blocked')} + : {t('waf.alerts.detected')}, + }, + { title: t('waf.alerts.col.hostname'), dataIndex: 'hostname', key: 'hostname', width: 180, + render: (v: string) => {v} }, + { title: t('waf.alerts.col.clientIp'), dataIndex: 'client_ip', key: 'client_ip', width: 120, + render: (v: string) => {v} }, + { 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) => {v} }, + { title: t('waf.alerts.col.ruleId'), dataIndex: 'rule_id', key: 'rule_id', width: 90, + render: (v: number) => {v} }, + { title: t('waf.alerts.col.severity'), dataIndex: 'severity', key: 'severity', width: 90, + render: (v: string) => {v || '—'} }, + { title: t('waf.alerts.col.msg'), dataIndex: 'rule_msg', key: 'rule_msg', ellipsis: true, + render: (v: string) => {v || '—'} }, + ] + + return ( +
+
+ + {(alerts ?? []).length} {t('waf.alerts.total')} + + purge.mutate()} + disabled={isViewer} + > + + +
+ {(alerts ?? []).length === 0 && !isLoading ? ( + + ) : ( + + row.action === 'blocked' ? 'fw-rule-row--zero-hit' : '' + } + /> + )} +
+ ) +} + // ---------- Page ------------------------------------------------------------ export default function WAFPage() { @@ -381,22 +502,34 @@ export default function WAFPage() { - - - { - const cfg = configMap.get(row.id) - return cfg?.enabled ? '' : 'fw-rule-row--disabled' - }} + + + { + const cfg = configMap.get(row.id) + return cfg?.enabled ? '' : 'fw-rule-row--disabled' + }} + /> + + ), + }, + { + key: 'alerts', + label: t('waf.tabs.alerts'), + children: , + }, + ]} />