feat(waf): benutzerdefinierte App-Profile + Fix: CRS-Plugins wurden im Agent nie geladen — v1.3.17

Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme-
Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar,
Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine
Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher).
- Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) +
  waf_configs.app_profiles.
- Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate).
- Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr
  updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei
  Profil-Edit.
- UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im
  Domain-Drawer.

FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents —
selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und
KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine
inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt
damit erstmals tatsächlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-08-03 15:51:39 +02:00
parent 0846eaa05b
commit 5c268425c1
10 changed files with 776 additions and 20 deletions

View File

@@ -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

View File

@@ -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 (160 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: "",

View File

@@ -15,6 +15,10 @@ type WafConfig struct {
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
// config/before/after-Dateien aus <crsDir>/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" }

View File

@@ -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
}

View File

@@ -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)
}
})
}

View File

@@ -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
}