diff --git a/VERSION b/VERSION index 287e5b8..a930824 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.16 \ No newline at end of file +1.3.17 \ No newline at end of file diff --git a/internal/database/migrations/0047_waf_app_profiles.sql b/internal/database/migrations/0047_waf_app_profiles.sql new file mode 100644 index 0000000..41f3a42 --- /dev/null +++ b/internal/database/migrations/0047_waf_app_profiles.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- +goose StatementBegin + +-- Benutzerdefinierte WAF-App-Profile: benannte, wiederverwendbare Bündel von +-- CRS-Rule-Exclusions (reine Rule-IDs/Ranges — keine SecLang-Ausführung, sicher). +-- Wirken wie die eingebauten OWASP-Plugins, sind aber im UI erstellbar/editierbar +-- und werden pro Domain zugewiesen (waf_configs.app_profiles). Die Auflösung in +-- effektive SecRuleRemoveById-Zeilen passiert im WAF-Agent (ListAllWithDomain). +-- +-- Repliziert (Config, kein node-lokaler Zustand) → vom cluster-reconcile +-- automatisch in edgeguard_shared aufgenommen (nicht in localOnlyTables). +CREATE TABLE IF NOT EXISTS waf_app_profiles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + rule_exclusions TEXT[] NOT NULL DEFAULT '{}', + builtin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Zuweisung Profil→Domain: Liste von Profil-Namen je waf_config. Beim Bauen der +-- Engine werden ihre rule_exclusions in die effektiven Ausnahmen der Domain +-- gemischt (zusätzlich zu den domain-eigenen rule_exclusions). +ALTER TABLE waf_configs + ADD COLUMN IF NOT EXISTS app_profiles TEXT[] NOT NULL DEFAULT '{}'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE waf_configs DROP COLUMN IF EXISTS app_profiles; +DROP TABLE IF EXISTS waf_app_profiles; +-- +goose StatementEnd diff --git a/internal/handlers/waf.go b/internal/handlers/waf.go index 21cbee7..7e3d8d6 100644 --- a/internal/handlers/waf.go +++ b/internal/handlers/waf.go @@ -46,6 +46,11 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) { g.PUT("/configs/:domain_id", h.Upsert) g.GET("/alerts", h.ListAlerts) g.DELETE("/alerts", h.PurgeAlerts) + // Benutzerdefinierte App-Profile (wiederverwendbare Rule-ID-Bündel). + g.GET("/profiles", h.ListProfiles) + g.POST("/profiles", h.CreateProfile) + g.PUT("/profiles/:id", h.UpdateProfile) + g.DELETE("/profiles/:id", h.DeleteProfile) } // List returns all WAF configs. @@ -86,6 +91,7 @@ type upsertBody struct { ParanoiaLevel int `json:"paranoia_level"` RuleExclusions []string `json:"rule_exclusions"` CRSPlugins []string `json:"crs_plugins"` + AppProfiles []string `json:"app_profiles"` ExclusionNotes map[string]string `json:"exclusion_notes"` TrustedProxies []string `json:"trusted_proxies"` CustomRules string `json:"custom_rules"` @@ -118,6 +124,18 @@ func (h *WafHandler) Upsert(c *gin.Context) { if body.TrustedProxies == nil { body.TrustedProxies = []string{} } + if body.AppProfiles == nil { + body.AppProfiles = []string{} + } + // App-Profile: nur trimmen/leere raus. Unbekannte Namen sind harmlos (der + // Agent-Resolver ignoriert sie defensiv), aber wir speichern keinen Müll. + cleanProfiles := make([]string, 0, len(body.AppProfiles)) + for _, p := range body.AppProfiles { + if p = strings.TrimSpace(p); p != "" { + cleanProfiles = append(cleanProfiles, p) + } + } + body.AppProfiles = cleanProfiles // CRS-Plugins müssen aus der bekannten Whitelist stammen — sie werden zu // Include-Pfaden, ein unbekannter Name wäre Pfad-Injection. for _, p := range body.CRSPlugins { @@ -158,6 +176,7 @@ func (h *WafHandler) Upsert(c *gin.Context) { ParanoiaLevel: body.ParanoiaLevel, RuleExclusions: body.RuleExclusions, CRSPlugins: body.CRSPlugins, + AppProfiles: body.AppProfiles, ExclusionNotes: body.ExclusionNotes, TrustedProxies: body.TrustedProxies, CustomRules: body.CustomRules, @@ -225,6 +244,119 @@ func (h *WafHandler) PurgeAlerts(c *gin.Context) { response.OK(c, gin.H{"ok": true, "days": days}) } +// wafProfileNameRe: erlaubte Zeichen für App-Profil-Namen (der Name wird pro +// Domain in waf_configs.app_profiles referenziert; kein SecLang-Kontext, aber +// sauber begrenzen). +var wafProfileNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,60}$`) + +// profileBody ist das akzeptierte JSON für Create/Update eines App-Profils. +type profileBody struct { + Name string `json:"name"` + Description string `json:"description"` + RuleExclusions []string `json:"rule_exclusions"` +} + +// validateProfileBody normalisiert und prüft den Request-Body. Gibt eine +// Fehlermeldung zurück (nil = ok) und mutiert body (trim, nil→[]). +func validateProfileBody(body *profileBody) error { + body.Name = strings.TrimSpace(body.Name) + if !wafProfileNameRe.MatchString(body.Name) { + return errors.New("ungültiger Profil-Name (1–60 Zeichen: Buchstaben, Ziffern, Leer, . _ -)") + } + body.Description = strings.TrimSpace(body.Description) + if body.RuleExclusions == nil { + body.RuleExclusions = []string{} + } + for i, ex := range body.RuleExclusions { + ex = strings.TrimSpace(ex) + if !wafRuleIDRe.MatchString(ex) { + return errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): " + ex) + } + body.RuleExclusions[i] = ex + } + return nil +} + +// ListProfiles returns all WAF app profiles (built-in first). +func (h *WafHandler) ListProfiles(c *gin.Context) { + profiles, err := h.Repo.ListProfiles(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"profiles": profiles}) +} + +// CreateProfile creates a new user-defined app profile. +func (h *WafHandler) CreateProfile(c *gin.Context) { + var body profileBody + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, err) + return + } + if err := validateProfileBody(&body); err != nil { + response.BadRequest(c, err) + return + } + p, err := h.Repo.CreateProfile(c.Request.Context(), body.Name, body.Description, body.RuleExclusions) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.create", body.Name, + gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID) + c.JSON(http.StatusOK, gin.H{"profile": p}) +} + +// UpdateProfile updates a user-defined app profile (built-ins are read-only). +func (h *WafHandler) UpdateProfile(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid id")) + return + } + var body profileBody + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, err) + return + } + if err := validateProfileBody(&body); err != nil { + response.BadRequest(c, err) + return + } + p, err := h.Repo.UpdateProfile(c.Request.Context(), id, body.Name, body.Description, body.RuleExclusions) + if err != nil { + if errors.Is(err, wafsvc.ErrProfileNotFound) { + response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)")) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.update", body.Name, + gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID) + c.JSON(http.StatusOK, gin.H{"profile": p}) +} + +// DeleteProfile removes a user-defined app profile (built-ins are protected). +func (h *WafHandler) DeleteProfile(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid id")) + return + } + if err := h.Repo.DeleteProfile(c.Request.Context(), id); err != nil { + if errors.Is(err, wafsvc.ErrProfileNotFound) { + response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)")) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.delete", strconv.FormatInt(id, 10), nil, h.NodeID) + response.OK(c, gin.H{"ok": true}) +} + // defaultConfig returns a sensible disabled default for a domain // that has no WAF config row yet. func defaultConfig(domainID int64) models.WafConfig { @@ -234,6 +366,8 @@ func defaultConfig(domainID int64) models.WafConfig { Mode: "detection", ParanoiaLevel: 1, RuleExclusions: []string{}, + CRSPlugins: []string{}, + AppProfiles: []string{}, ExclusionNotes: map[string]string{}, TrustedProxies: []string{}, CustomRules: "", diff --git a/internal/models/waf.go b/internal/models/waf.go index 7776d2d..7a52211 100644 --- a/internal/models/waf.go +++ b/internal/models/waf.go @@ -15,6 +15,10 @@ type WafConfig struct { // "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen // config/before/after-Dateien aus /plugins/. CRSPlugins []string `gorm:"column:crs_plugins;type:text[]" json:"crs_plugins"` + // AppProfiles: zugewiesene benutzerdefinierte WAF-App-Profile (Namen aus + // waf_app_profiles). Ihre rule_exclusions werden im Agent in die effektiven + // Ausnahmen dieser Domain gemischt. + AppProfiles []string `gorm:"column:app_profiles;type:text[]" json:"app_profiles"` 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"` @@ -22,3 +26,18 @@ type WafConfig struct { } func (WafConfig) TableName() string { return "waf_configs" } + +// WafAppProfile ist ein benanntes, wiederverwendbares Bündel von CRS-Rule- +// Exclusions (reine Rule-IDs/Ranges). Built-in-Profile (builtin=true) sind +// read-only; benutzerdefinierte sind im UI editierbar und pro Domain zuweisbar. +type WafAppProfile struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + Description string `gorm:"column:description" json:"description"` + RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"` + Builtin bool `gorm:"column:builtin" json:"builtin"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (WafAppProfile) TableName() string { return "waf_app_profiles" } diff --git a/internal/services/waf/appprofiles.go b/internal/services/waf/appprofiles.go new file mode 100644 index 0000000..42c94db --- /dev/null +++ b/internal/services/waf/appprofiles.go @@ -0,0 +1,124 @@ +package waf + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +// ErrProfileNotFound wird von Get/Update/Delete zurückgegeben, wenn kein Profil +// mit der ID existiert. +var ErrProfileNotFound = errors.New("waf app profile not found") + +const profileSelect = ` +SELECT id, name, description, rule_exclusions, builtin, created_at, updated_at +FROM waf_app_profiles +` + +func scanProfile(row pgx.Row) (*models.WafAppProfile, error) { + var p models.WafAppProfile + if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.RuleExclusions, &p.Builtin, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + return &p, nil +} + +// ListProfiles gibt alle App-Profile zurück (Built-in zuerst, dann alphabetisch). +func (r *Repo) ListProfiles(ctx context.Context) ([]models.WafAppProfile, error) { + rows, err := r.Pool.Query(ctx, profileSelect+" ORDER BY builtin DESC, name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WafAppProfile, 0, 16) + for rows.Next() { + p, err := scanProfile(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +// profilesByName lädt alle Profile in eine Name→Profil-Map (für die Auflösung +// im Agent-Loader). +func (r *Repo) profilesByName(ctx context.Context) (map[string]models.WafAppProfile, error) { + list, err := r.ListProfiles(ctx) + if err != nil { + return nil, err + } + m := make(map[string]models.WafAppProfile, len(list)) + for _, p := range list { + m[p.Name] = p + } + return m, nil +} + +// GetProfile gibt ein Profil per ID zurück, oder ErrProfileNotFound. +func (r *Repo) GetProfile(ctx context.Context, id int64) (*models.WafAppProfile, error) { + row := r.Pool.QueryRow(ctx, profileSelect+" WHERE id = $1", id) + p, err := scanProfile(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProfileNotFound + } + return nil, err + } + return p, nil +} + +// CreateProfile legt ein neues benutzerdefiniertes Profil an (builtin immer +// false — Built-ins werden nicht über die API erzeugt). +func (r *Repo) CreateProfile(ctx context.Context, name, description string, exclusions []string) (*models.WafAppProfile, error) { + if exclusions == nil { + exclusions = []string{} + } + now := time.Now() + row := r.Pool.QueryRow(ctx, ` + INSERT INTO waf_app_profiles (name, description, rule_exclusions, builtin, created_at, updated_at) + VALUES ($1,$2,$3,false,$4,$4) + RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at + `, name, description, exclusions, now) + return scanProfile(row) +} + +// UpdateProfile ändert Name/Beschreibung/Ausnahmen eines Profils. Built-in- +// Profile sind read-only (WHERE builtin = false) → ErrProfileNotFound, wenn +// das Profil fehlt ODER built-in ist. +func (r *Repo) UpdateProfile(ctx context.Context, id int64, name, description string, exclusions []string) (*models.WafAppProfile, error) { + if exclusions == nil { + exclusions = []string{} + } + row := r.Pool.QueryRow(ctx, ` + UPDATE waf_app_profiles + SET name = $2, description = $3, rule_exclusions = $4, updated_at = $5 + WHERE id = $1 AND builtin = false + RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at + `, id, name, description, exclusions, time.Now()) + p, err := scanProfile(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProfileNotFound + } + return nil, err + } + return p, nil +} + +// DeleteProfile entfernt ein benutzerdefiniertes Profil. Built-in-Profile sind +// geschützt. Gibt ErrProfileNotFound zurück, wenn nichts gelöscht wurde. +func (r *Repo) DeleteProfile(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM waf_app_profiles WHERE id = $1 AND builtin = false`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrProfileNotFound + } + return nil +} diff --git a/internal/services/waf/appprofiles_test.go b/internal/services/waf/appprofiles_test.go new file mode 100644 index 0000000..795aef7 --- /dev/null +++ b/internal/services/waf/appprofiles_test.go @@ -0,0 +1,48 @@ +package waf + +import ( + "testing" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +func TestMergeProfileExclusions(t *testing.T) { + base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC) + newer := base.Add(1 * time.Hour) + profiles := map[string]models.WafAppProfile{ + "nc": {Name: "nc", RuleExclusions: []string{"942100", "920420"}, UpdatedAt: newer}, + "wp": {Name: "wp", RuleExclusions: []string{"942100", "941100"}, UpdatedAt: base}, + } + + t.Run("keine Profile → unverändert", func(t *testing.T) { + got, ts := mergeProfileExclusions([]string{"1000"}, base, nil, profiles) + if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) { + t.Fatalf("got=%v ts=%v", got, ts) + } + }) + + t.Run("union dedupliziert, Reihenfolge stabil", func(t *testing.T) { + got, ts := mergeProfileExclusions([]string{"942100", "900001"}, base, []string{"nc", "wp"}, profiles) + want := []string{"942100", "900001", "920420", "941100"} // 942100 nicht doppelt + if len(got) != len(want) { + t.Fatalf("got=%v want=%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got=%v want=%v", got, want) + } + } + // effektives updated_at = max(base, nc.newer) = newer + if !ts.Equal(newer) { + t.Fatalf("ts=%v want=%v (Profil-Edit muss Rebuild ausloesen)", ts, newer) + } + }) + + t.Run("unbekanntes Profil defensiv ignoriert", func(t *testing.T) { + got, ts := mergeProfileExclusions([]string{"1000"}, base, []string{"gibtsnicht"}, profiles) + if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) { + t.Fatalf("got=%v ts=%v", got, ts) + } + }) +} diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go index 17232e6..d937956 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, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at + rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at FROM waf_configs ` @@ -30,7 +30,7 @@ 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.CRSPlugins, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, + &c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, ) if err != nil { return nil, err @@ -82,23 +82,24 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi row := r.Pool.QueryRow(ctx, ` INSERT INTO waf_configs (domain_id, enabled, mode, paranoia_level, - rule_exclusions, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (domain_id) DO UPDATE SET enabled = EXCLUDED.enabled, mode = EXCLUDED.mode, paranoia_level = EXCLUDED.paranoia_level, rule_exclusions = EXCLUDED.rule_exclusions, crs_plugins = EXCLUDED.crs_plugins, + app_profiles = EXCLUDED.app_profiles, 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, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at + rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at `, c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel, - c.RuleExclusions, c.CRSPlugins, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt, + c.RuleExclusions, c.CRSPlugins, c.AppProfiles, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt, ) return scan(row) } @@ -201,11 +202,23 @@ type DomainConfigPair struct { // ListAllWithDomain returns all WAF configs joined with their domain name. // Used by the WAF agent to build the hostname→engine mapping. +// +// Wichtig: crs_plugins UND app_profiles werden hier geladen — früher fehlte +// crs_plugins, dadurch waren die gewählten Built-in-CRS-Plugins im laufenden +// Agent nie aktiv. app_profiles (benutzerdefinierte Rule-ID-Bündel) werden hier +// in die effektiven rule_exclusions der Domain gemischt und ihr updated_at +// fließt in das effektive updated_at ein — so baut der Manager die Engine neu, +// sobald ein Profil bearbeitet wird (der Rebuild-Trigger hängt an updated_at). func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) { + profiles, err := r.profilesByName(ctx) + if err != nil { + return nil, err + } rows, err := r.Pool.Query(ctx, ` SELECT d.name, w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level, - w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at + w.rule_exclusions, w.crs_plugins, w.app_profiles, + w.trusted_proxies, w.custom_rules, w.updated_at FROM waf_configs w JOIN domains d ON d.id = w.domain_id WHERE d.active = true @@ -222,12 +235,49 @@ func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error if err := rows.Scan( &p.Hostname, &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, - &c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, + &c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles, + &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, ); err != nil { return nil, err } + c.RuleExclusions, c.UpdatedAt = mergeProfileExclusions(c.RuleExclusions, c.UpdatedAt, c.AppProfiles, profiles) p.Config = c out = append(out, p) } return out, rows.Err() } + +// mergeProfileExclusions vereint die domain-eigenen Ausnahmen mit denen aller +// zugewiesenen App-Profile (dedupliziert, stabile Reihenfolge) und hebt das +// effektive updated_at auf das Maximum aus Config + zugewiesenen Profilen an. +// Pure Funktion (leicht testbar, keine DB). +func mergeProfileExclusions(own []string, updatedAt time.Time, assigned []string, profiles map[string]models.WafAppProfile) ([]string, time.Time) { + if len(assigned) == 0 { + return own, updatedAt + } + seen := make(map[string]struct{}, len(own)) + merged := make([]string, 0, len(own)) + for _, id := range own { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + merged = append(merged, id) + } + } + effUpdated := updatedAt + for _, name := range assigned { + prof, ok := profiles[name] + if !ok { + continue // unbekanntes/gelöschtes Profil defensiv ignorieren + } + if prof.UpdatedAt.After(effUpdated) { + effUpdated = prof.UpdatedAt + } + for _, id := range prof.RuleExclusions { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + merged = append(merged, id) + } + } + } + return merged, effUpdated +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 3f25d77..9638860 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -1880,7 +1880,11 @@ "deleteFailed": "Löschen fehlgeschlagen", "secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.", "secretUnset": "Noch nichts gespeichert.", - "tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" }, + "tabs": { + "settings": "Einstellungen", + "clients": "Clients (NAS)", + "users": "Benutzer" + }, "settings": { "enabled": "RADIUS auf dieser Node aktiv", "listen": "Listen-Adressen" @@ -1951,11 +1955,15 @@ "exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…", "exclusionAddNote": "Notiz (optional)", "exclusionAddNotFound": "Keine Regel gefunden", - "exclusionAddBtn": "Hinzufügen" + "exclusionAddBtn": "Hinzufügen", + "appProfiles": "Eigene App-Profile", + "appProfilesHint": "Wiederverwendbare, selbst gepflegte Profile (Regel-ID-Bündel) — zentral unter „App-Profile“ anlegen und hier pro Domain zuweisen. Ergänzt die OWASP-Plugins oben.", + "appProfilesPlaceholder": "Eigene Profile wählen (optional)" }, "tabs": { "domains": "Domains", - "alerts": "Alarme" + "alerts": "Alarme", + "profiles": "App-Profile" }, "alerts": { "total": "Einträge", @@ -1984,6 +1992,39 @@ "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.", "alreadyExcluded": "Bereits Ausnahme" + }, + "profiles": { + "intro": "Wiederverwendbare Ausnahme-Profile (Bündel von CRS-Regel-IDs). Einmal anlegen, pro Domain zuweisen. Die eingebauten OWASP-Plugins sind read-only und werden pro Domain gewählt.", + "new": "Neues Profil", + "empty": "Noch keine eigenen Profile. Lege eins an, um Regel-Ausnahmen wiederzuverwenden.", + "builtinInfo": "Eingebaute OWASP-CRS-Plugins (dateibasiert, gepflegt). Read-only — pro Domain im WAF-Drawer wählbar. „Als Vorlage“ erstellt daraus ein leeres eigenes Profil zum Befüllen.", + "typeBuiltin": "OWASP", + "typeCustom": "Eigen", + "col": { + "name": "Name", + "type": "Typ", + "description": "Beschreibung", + "rules": "Ausnahmen" + }, + "edit": "Bearbeiten", + "delete": "Löschen", + "clone": "Klonen", + "asTemplate": "Als Vorlage", + "deleteConfirm": "Profil wirklich löschen? Zuweisungen an Domains verlieren dann diese Ausnahmen.", + "createTitle": "Neues App-Profil", + "editTitle": "App-Profil bearbeiten", + "name": "Name", + "namePlaceholder": "z.B. Meine WebApp", + "description": "Beschreibung", + "descriptionPlaceholder": "Wofür ist dieses Profil? (optional)", + "rules": "Regel-Ausnahmen", + "rulesHint": "CRS-Regel-IDs die für zugewiesene Domains deaktiviert werden. Durchsuchbar nach ID oder Beschreibung.", + "rulesPlaceholder": "Regel-IDs suchen und hinzufügen…", + "saved": "Profil gespeichert.", + "saveFailed": "Profil konnte nicht gespeichert werden.", + "deleted": "Profil gelöscht.", + "deleteFailed": "Profil konnte nicht gelöscht werden.", + "cloneSuffix": "Kopie" } } -} \ 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 03f18df..fc57163 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -1880,7 +1880,11 @@ "deleteFailed": "Delete failed", "secretSet": "Stored — leave empty to keep unchanged.", "secretUnset": "Nothing stored yet.", - "tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" }, + "tabs": { + "settings": "Settings", + "clients": "Clients (NAS)", + "users": "Users" + }, "settings": { "enabled": "RADIUS active on this node", "listen": "Listen addresses" @@ -1951,11 +1955,15 @@ "exclusionAddPlaceholder": "Search rule (ID or description)…", "exclusionAddNote": "Note (optional)", "exclusionAddNotFound": "No rule found", - "exclusionAddBtn": "Add" + "exclusionAddBtn": "Add", + "appProfiles": "Custom App Profiles", + "appProfilesHint": "Reusable, self-maintained profiles (bundles of rule IDs) — create them centrally under “App Profiles” and assign them here per domain. Complements the OWASP plugins above.", + "appProfilesPlaceholder": "Select custom profiles (optional)" }, "tabs": { "domains": "Domains", - "alerts": "Alerts" + "alerts": "Alerts", + "profiles": "App Profiles" }, "alerts": { "total": "entries", @@ -1984,6 +1992,39 @@ "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.", "alreadyExcluded": "Already excluded" + }, + "profiles": { + "intro": "Reusable exclusion profiles (bundles of CRS rule IDs). Create once, assign per domain. The built-in OWASP plugins are read-only and selected per domain.", + "new": "New Profile", + "empty": "No custom profiles yet. Create one to reuse rule exclusions.", + "builtinInfo": "Built-in OWASP CRS plugins (file-based, maintained). Read-only — selectable per domain in the WAF drawer. “Use as template” creates an empty custom profile from it to fill in.", + "typeBuiltin": "OWASP", + "typeCustom": "Custom", + "col": { + "name": "Name", + "type": "Type", + "description": "Description", + "rules": "Exclusions" + }, + "edit": "Edit", + "delete": "Delete", + "clone": "Clone", + "asTemplate": "Use as template", + "deleteConfirm": "Really delete this profile? Domains using it will lose these exclusions.", + "createTitle": "New App Profile", + "editTitle": "Edit App Profile", + "name": "Name", + "namePlaceholder": "e.g. My WebApp", + "description": "Description", + "descriptionPlaceholder": "What is this profile for? (optional)", + "rules": "Rule Exclusions", + "rulesHint": "CRS rule IDs disabled for assigned domains. Searchable by ID or description.", + "rulesPlaceholder": "Search and add rule IDs…", + "saved": "Profile saved.", + "saveFailed": "Could not save profile.", + "deleted": "Profile deleted.", + "deleteFailed": "Could not delete profile.", + "cloneSuffix": "Copy" } } -} \ 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 5d33db3..e8f0099 100644 --- a/management-ui/src/pages/WAF/index.tsx +++ b/management-ui/src/pages/WAF/index.tsx @@ -1,11 +1,12 @@ -import { useMemo, useState } from 'react' +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, DeleteOutlined, PlusOutlined, - SafetyCertificateOutlined, SettingOutlined, WarningOutlined, + 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' @@ -34,11 +35,22 @@ interface WafConfig { paranoia_level: number rule_exclusions: string[] crs_plugins: string[] + app_profiles: string[] exclusion_notes: Record 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' }, @@ -74,17 +86,25 @@ function defaultConfig(domainId: number): WafConfig { paranoia_level: 1, rule_exclusions: [], crs_plugins: [], + app_profiles: [], exclusion_notes: {}, trusted_proxies: [], custom_rules: '', } } +async function fetchProfiles(): Promise { + 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 } @@ -117,6 +137,13 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { 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), @@ -154,6 +181,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { layout="vertical" initialValues={{ ...cfg, + app_profiles: cfg.app_profiles ?? [], trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '), }} onFinish={(vals) => { @@ -166,6 +194,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { 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 ?? '', @@ -223,6 +252,21 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { /> + + + + + + + +