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:
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal file
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal 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
|
||||||
@@ -46,6 +46,11 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.PUT("/configs/:domain_id", h.Upsert)
|
g.PUT("/configs/:domain_id", h.Upsert)
|
||||||
g.GET("/alerts", h.ListAlerts)
|
g.GET("/alerts", h.ListAlerts)
|
||||||
g.DELETE("/alerts", h.PurgeAlerts)
|
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.
|
// List returns all WAF configs.
|
||||||
@@ -86,6 +91,7 @@ type upsertBody struct {
|
|||||||
ParanoiaLevel int `json:"paranoia_level"`
|
ParanoiaLevel int `json:"paranoia_level"`
|
||||||
RuleExclusions []string `json:"rule_exclusions"`
|
RuleExclusions []string `json:"rule_exclusions"`
|
||||||
CRSPlugins []string `json:"crs_plugins"`
|
CRSPlugins []string `json:"crs_plugins"`
|
||||||
|
AppProfiles []string `json:"app_profiles"`
|
||||||
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
||||||
TrustedProxies []string `json:"trusted_proxies"`
|
TrustedProxies []string `json:"trusted_proxies"`
|
||||||
CustomRules string `json:"custom_rules"`
|
CustomRules string `json:"custom_rules"`
|
||||||
@@ -118,6 +124,18 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
|||||||
if body.TrustedProxies == nil {
|
if body.TrustedProxies == nil {
|
||||||
body.TrustedProxies = []string{}
|
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
|
// CRS-Plugins müssen aus der bekannten Whitelist stammen — sie werden zu
|
||||||
// Include-Pfaden, ein unbekannter Name wäre Pfad-Injection.
|
// Include-Pfaden, ein unbekannter Name wäre Pfad-Injection.
|
||||||
for _, p := range body.CRSPlugins {
|
for _, p := range body.CRSPlugins {
|
||||||
@@ -158,6 +176,7 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
|||||||
ParanoiaLevel: body.ParanoiaLevel,
|
ParanoiaLevel: body.ParanoiaLevel,
|
||||||
RuleExclusions: body.RuleExclusions,
|
RuleExclusions: body.RuleExclusions,
|
||||||
CRSPlugins: body.CRSPlugins,
|
CRSPlugins: body.CRSPlugins,
|
||||||
|
AppProfiles: body.AppProfiles,
|
||||||
ExclusionNotes: body.ExclusionNotes,
|
ExclusionNotes: body.ExclusionNotes,
|
||||||
TrustedProxies: body.TrustedProxies,
|
TrustedProxies: body.TrustedProxies,
|
||||||
CustomRules: body.CustomRules,
|
CustomRules: body.CustomRules,
|
||||||
@@ -225,6 +244,119 @@ func (h *WafHandler) PurgeAlerts(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"ok": true, "days": days})
|
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
|
// defaultConfig returns a sensible disabled default for a domain
|
||||||
// that has no WAF config row yet.
|
// that has no WAF config row yet.
|
||||||
func defaultConfig(domainID int64) models.WafConfig {
|
func defaultConfig(domainID int64) models.WafConfig {
|
||||||
@@ -234,6 +366,8 @@ func defaultConfig(domainID int64) models.WafConfig {
|
|||||||
Mode: "detection",
|
Mode: "detection",
|
||||||
ParanoiaLevel: 1,
|
ParanoiaLevel: 1,
|
||||||
RuleExclusions: []string{},
|
RuleExclusions: []string{},
|
||||||
|
CRSPlugins: []string{},
|
||||||
|
AppProfiles: []string{},
|
||||||
ExclusionNotes: map[string]string{},
|
ExclusionNotes: map[string]string{},
|
||||||
TrustedProxies: []string{},
|
TrustedProxies: []string{},
|
||||||
CustomRules: "",
|
CustomRules: "",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type WafConfig struct {
|
|||||||
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
|
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
|
||||||
// config/before/after-Dateien aus <crsDir>/plugins/.
|
// config/before/after-Dateien aus <crsDir>/plugins/.
|
||||||
CRSPlugins []string `gorm:"column:crs_plugins;type:text[]" json:"crs_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
|
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"`
|
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
|
||||||
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
|
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
|
||||||
@@ -22,3 +26,18 @@ type WafConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (WafConfig) TableName() string { return "waf_configs" }
|
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" }
|
||||||
|
|||||||
124
internal/services/waf/appprofiles.go
Normal file
124
internal/services/waf/appprofiles.go
Normal 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
|
||||||
|
}
|
||||||
48
internal/services/waf/appprofiles_test.go
Normal file
48
internal/services/waf/appprofiles_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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, 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
|
FROM waf_configs
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ 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.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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,23 +82,24 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi
|
|||||||
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, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
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,
|
||||||
crs_plugins = EXCLUDED.crs_plugins,
|
crs_plugins = EXCLUDED.crs_plugins,
|
||||||
|
app_profiles = EXCLUDED.app_profiles,
|
||||||
exclusion_notes = EXCLUDED.exclusion_notes,
|
exclusion_notes = EXCLUDED.exclusion_notes,
|
||||||
trusted_proxies = EXCLUDED.trusted_proxies,
|
trusted_proxies = EXCLUDED.trusted_proxies,
|
||||||
custom_rules = EXCLUDED.custom_rules,
|
custom_rules = EXCLUDED.custom_rules,
|
||||||
updated_at = EXCLUDED.updated_at
|
updated_at = EXCLUDED.updated_at
|
||||||
RETURNING id, domain_id, enabled, mode, paranoia_level,
|
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.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)
|
return scan(row)
|
||||||
}
|
}
|
||||||
@@ -201,11 +202,23 @@ type DomainConfigPair struct {
|
|||||||
|
|
||||||
// ListAllWithDomain returns all WAF configs joined with their domain name.
|
// ListAllWithDomain returns all WAF configs joined with their domain name.
|
||||||
// Used by the WAF agent to build the hostname→engine mapping.
|
// 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) {
|
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, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT d.name,
|
SELECT d.name,
|
||||||
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
|
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
|
FROM waf_configs w
|
||||||
JOIN domains d ON d.id = w.domain_id
|
JOIN domains d ON d.id = w.domain_id
|
||||||
WHERE d.active = true
|
WHERE d.active = true
|
||||||
@@ -222,12 +235,49 @@ func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.Hostname,
|
&p.Hostname,
|
||||||
&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.CRSPlugins, &c.AppProfiles,
|
||||||
|
&c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.RuleExclusions, c.UpdatedAt = mergeProfileExclusions(c.RuleExclusions, c.UpdatedAt, c.AppProfiles, profiles)
|
||||||
p.Config = c
|
p.Config = c
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1880,7 +1880,11 @@
|
|||||||
"deleteFailed": "Löschen fehlgeschlagen",
|
"deleteFailed": "Löschen fehlgeschlagen",
|
||||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||||
"secretUnset": "Noch nichts gespeichert.",
|
"secretUnset": "Noch nichts gespeichert.",
|
||||||
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
|
"tabs": {
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"clients": "Clients (NAS)",
|
||||||
|
"users": "Benutzer"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"enabled": "RADIUS auf dieser Node aktiv",
|
"enabled": "RADIUS auf dieser Node aktiv",
|
||||||
"listen": "Listen-Adressen"
|
"listen": "Listen-Adressen"
|
||||||
@@ -1951,11 +1955,15 @@
|
|||||||
"exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…",
|
"exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…",
|
||||||
"exclusionAddNote": "Notiz (optional)",
|
"exclusionAddNote": "Notiz (optional)",
|
||||||
"exclusionAddNotFound": "Keine Regel gefunden",
|
"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": {
|
"tabs": {
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
"alerts": "Alarme"
|
"alerts": "Alarme",
|
||||||
|
"profiles": "App-Profile"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"total": "Einträge",
|
"total": "Einträge",
|
||||||
@@ -1984,6 +1992,39 @@
|
|||||||
"exceptionModalHint": "Optional: Begründung warum diese Regel ein False Positive für diese Domain ist.",
|
"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.",
|
"exceptionNotePlaceholder": "z.B. Unsere API verwendet nicht-standardisierte Header die diese Regel auslösen.",
|
||||||
"alreadyExcluded": "Bereits Ausnahme"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1880,7 +1880,11 @@
|
|||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||||
"secretUnset": "Nothing stored yet.",
|
"secretUnset": "Nothing stored yet.",
|
||||||
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
|
"tabs": {
|
||||||
|
"settings": "Settings",
|
||||||
|
"clients": "Clients (NAS)",
|
||||||
|
"users": "Users"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"enabled": "RADIUS active on this node",
|
"enabled": "RADIUS active on this node",
|
||||||
"listen": "Listen addresses"
|
"listen": "Listen addresses"
|
||||||
@@ -1951,11 +1955,15 @@
|
|||||||
"exclusionAddPlaceholder": "Search rule (ID or description)…",
|
"exclusionAddPlaceholder": "Search rule (ID or description)…",
|
||||||
"exclusionAddNote": "Note (optional)",
|
"exclusionAddNote": "Note (optional)",
|
||||||
"exclusionAddNotFound": "No rule found",
|
"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": {
|
"tabs": {
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
"alerts": "Alerts"
|
"alerts": "Alerts",
|
||||||
|
"profiles": "App Profiles"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"total": "entries",
|
"total": "entries",
|
||||||
@@ -1984,6 +1992,39 @@
|
|||||||
"exceptionModalHint": "Optional: describe why this rule is a false positive for this domain.",
|
"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.",
|
"exceptionNotePlaceholder": "e.g. Our custom API uses non-standard headers that trigger this rule.",
|
||||||
"alreadyExcluded": "Already excluded"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Drawer, Form, Input, Modal, 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 {
|
||||||
CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, PlusOutlined,
|
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
|
||||||
SafetyCertificateOutlined, SettingOutlined, WarningOutlined,
|
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
|
||||||
|
WarningOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -34,11 +35,22 @@ interface WafConfig {
|
|||||||
paranoia_level: number
|
paranoia_level: number
|
||||||
rule_exclusions: string[]
|
rule_exclusions: string[]
|
||||||
crs_plugins: string[]
|
crs_plugins: string[]
|
||||||
|
app_profiles: string[]
|
||||||
exclusion_notes: Record<string, string>
|
exclusion_notes: Record<string, string>
|
||||||
trusted_proxies: string[]
|
trusted_proxies: string[]
|
||||||
custom_rules: 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.
|
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
|
||||||
const CRS_PLUGIN_OPTIONS = [
|
const CRS_PLUGIN_OPTIONS = [
|
||||||
{ value: 'nextcloud', label: 'Nextcloud' },
|
{ value: 'nextcloud', label: 'Nextcloud' },
|
||||||
@@ -74,17 +86,25 @@ function defaultConfig(domainId: number): WafConfig {
|
|||||||
paranoia_level: 1,
|
paranoia_level: 1,
|
||||||
rule_exclusions: [],
|
rule_exclusions: [],
|
||||||
crs_plugins: [],
|
crs_plugins: [],
|
||||||
|
app_profiles: [],
|
||||||
exclusion_notes: {},
|
exclusion_notes: {},
|
||||||
trusted_proxies: [],
|
trusted_proxies: [],
|
||||||
custom_rules: '',
|
custom_rules: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchProfiles(): Promise<WafProfile[]> {
|
||||||
|
const r = await apiClient.get('/waf/profiles')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
|
||||||
|
}
|
||||||
|
|
||||||
interface WafFormValues {
|
interface WafFormValues {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
mode: 'detection' | 'blocking'
|
mode: 'detection' | 'blocking'
|
||||||
paranoia_level: number
|
paranoia_level: number
|
||||||
crs_plugins: string[]
|
crs_plugins: string[]
|
||||||
|
app_profiles: string[]
|
||||||
trusted_proxies_str: string
|
trusted_proxies_str: string
|
||||||
custom_rules: string
|
custom_rules: string
|
||||||
}
|
}
|
||||||
@@ -117,6 +137,13 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
enabled: domainId !== null,
|
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({
|
const save = useMutation({
|
||||||
mutationFn: (values: WafConfig) =>
|
mutationFn: (values: WafConfig) =>
|
||||||
apiClient.put(`/waf/configs/${domainId}`, values),
|
apiClient.put(`/waf/configs/${domainId}`, values),
|
||||||
@@ -154,6 +181,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
...cfg,
|
...cfg,
|
||||||
|
app_profiles: cfg.app_profiles ?? [],
|
||||||
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
||||||
}}
|
}}
|
||||||
onFinish={(vals) => {
|
onFinish={(vals) => {
|
||||||
@@ -166,6 +194,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
paranoia_level: vals.paranoia_level,
|
paranoia_level: vals.paranoia_level,
|
||||||
rule_exclusions: cfg?.rule_exclusions ?? [],
|
rule_exclusions: cfg?.rule_exclusions ?? [],
|
||||||
crs_plugins: vals.crs_plugins ?? [],
|
crs_plugins: vals.crs_plugins ?? [],
|
||||||
|
app_profiles: vals.app_profiles ?? [],
|
||||||
exclusion_notes: cfg?.exclusion_notes ?? {},
|
exclusion_notes: cfg?.exclusion_notes ?? {},
|
||||||
trusted_proxies: proxies,
|
trusted_proxies: proxies,
|
||||||
custom_rules: vals.custom_rules ?? '',
|
custom_rules: vals.custom_rules ?? '',
|
||||||
@@ -223,6 +252,21 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.config.appProfiles')}
|
||||||
|
name="app_profiles"
|
||||||
|
help={t('waf.config.appProfilesHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
disabled={isViewer}
|
||||||
|
placeholder={t('waf.config.appProfilesPlaceholder')}
|
||||||
|
options={profileOptions}
|
||||||
|
notFoundContent={t('waf.profiles.empty')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
{/* Exclusions list — shows existing exclusions with notes + remove button */}
|
{/* Exclusions list — shows existing exclusions with notes + remove button */}
|
||||||
<Form.Item label={t('waf.config.exclusions')}>
|
<Form.Item label={t('waf.config.exclusions')}>
|
||||||
{(cfg?.rule_exclusions ?? []).length === 0 ? (
|
{(cfg?.rule_exclusions ?? []).length === 0 ? (
|
||||||
@@ -258,6 +302,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
paranoia_level: cfg?.paranoia_level ?? 1,
|
paranoia_level: cfg?.paranoia_level ?? 1,
|
||||||
rule_exclusions: newExclusions,
|
rule_exclusions: newExclusions,
|
||||||
crs_plugins: cfg?.crs_plugins ?? [],
|
crs_plugins: cfg?.crs_plugins ?? [],
|
||||||
|
app_profiles: cfg?.app_profiles ?? [],
|
||||||
exclusion_notes: newNotes,
|
exclusion_notes: newNotes,
|
||||||
trusted_proxies: cfg?.trusted_proxies ?? [],
|
trusted_proxies: cfg?.trusted_proxies ?? [],
|
||||||
custom_rules: cfg?.custom_rules ?? '',
|
custom_rules: cfg?.custom_rules ?? '',
|
||||||
@@ -306,6 +351,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
|||||||
paranoia_level: cfg?.paranoia_level ?? 1,
|
paranoia_level: cfg?.paranoia_level ?? 1,
|
||||||
rule_exclusions: [...existing, addRuleId],
|
rule_exclusions: [...existing, addRuleId],
|
||||||
crs_plugins: cfg?.crs_plugins ?? [],
|
crs_plugins: cfg?.crs_plugins ?? [],
|
||||||
|
app_profiles: cfg?.app_profiles ?? [],
|
||||||
exclusion_notes: newNotes,
|
exclusion_notes: newNotes,
|
||||||
trusted_proxies: cfg?.trusted_proxies ?? [],
|
trusted_proxies: cfg?.trusted_proxies ?? [],
|
||||||
custom_rules: cfg?.custom_rules ?? '',
|
custom_rules: cfg?.custom_rules ?? '',
|
||||||
@@ -562,6 +608,220 @@ function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map<
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Profiles tab ----------------------------------------------------
|
||||||
|
|
||||||
|
interface ProfileFormValues {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
rule_exclusions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [form] = Form.useForm<ProfileFormValues>()
|
||||||
|
const isCreate = profile !== null && profile.id === 0
|
||||||
|
const ruleOptions = useMemo(
|
||||||
|
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Formular bei jedem Öffnen/Wechsel neu befüllen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: profile.name,
|
||||||
|
description: profile.description,
|
||||||
|
rule_exclusions: profile.rule_exclusions ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [profile, form])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (vals: ProfileFormValues) =>
|
||||||
|
isCreate
|
||||||
|
? apiClient.post('/waf/profiles', vals)
|
||||||
|
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('waf.profiles.saved'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: () => message.error(t('waf.profiles.saveFailed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
|
||||||
|
open={profile !== null}
|
||||||
|
onClose={onClose}
|
||||||
|
width={520}
|
||||||
|
footer={
|
||||||
|
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
|
||||||
|
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||||
|
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(vals) => save.mutate({
|
||||||
|
name: vals.name,
|
||||||
|
description: vals.description ?? '',
|
||||||
|
rule_exclusions: vals.rule_exclusions ?? [],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.profiles.name')}
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, max: 60 }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('waf.profiles.namePlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('waf.profiles.description')} name="description">
|
||||||
|
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.profiles.rules')}
|
||||||
|
name="rule_exclusions"
|
||||||
|
help={t('waf.profiles.rulesHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
placeholder={t('waf.profiles.rulesPlaceholder')}
|
||||||
|
options={ruleOptions}
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilesTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||||
|
const [editing, setEditing] = useState<WafProfile | null>(null)
|
||||||
|
|
||||||
|
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
|
||||||
|
const custom = (profiles ?? []).filter(p => !p.builtin)
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('waf.profiles.deleted'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
||||||
|
},
|
||||||
|
onError: () => message.error(t('waf.profiles.deleteFailed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
|
||||||
|
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
|
||||||
|
id: 0,
|
||||||
|
name: seed?.name ?? '',
|
||||||
|
description: seed?.description ?? '',
|
||||||
|
rule_exclusions: seed?.rule_exclusions ?? [],
|
||||||
|
builtin: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('waf.profiles.col.name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('waf.profiles.col.description'),
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('waf.profiles.col.rules'),
|
||||||
|
key: 'rules',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 210,
|
||||||
|
render: (_: unknown, row: WafProfile) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
|
||||||
|
{t('waf.profiles.edit')}
|
||||||
|
</Button>
|
||||||
|
<Tooltip title={t('waf.profiles.clone')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
disabled={isViewer}
|
||||||
|
onClick={() => openCreate({
|
||||||
|
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
|
||||||
|
description: row.description,
|
||||||
|
rule_exclusions: row.rule_exclusions,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="mb-16"
|
||||||
|
message={t('waf.profiles.intro')}
|
||||||
|
description={
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{CRS_PLUGIN_OPTIONS.map(p => (
|
||||||
|
<Space key={p.value} size={2}>
|
||||||
|
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
|
||||||
|
{!isViewer && (
|
||||||
|
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
|
||||||
|
{t('waf.profiles.asTemplate')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-between mb-12">
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{custom.length} {t('waf.profiles.typeCustom')}
|
||||||
|
</Text>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
|
||||||
|
{t('waf.profiles.new')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{custom.length === 0 && !isLoading ? (
|
||||||
|
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
|
||||||
|
) : (
|
||||||
|
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
|
||||||
|
)}
|
||||||
|
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Page ------------------------------------------------------------
|
// ---------- Page ------------------------------------------------------------
|
||||||
|
|
||||||
export default function WAFPage() {
|
export default function WAFPage() {
|
||||||
@@ -735,6 +995,11 @@ export default function WAFPage() {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'profiles',
|
||||||
|
label: t('waf.tabs.profiles'),
|
||||||
|
children: <ProfilesTab />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'alerts',
|
key: 'alerts',
|
||||||
label: t('waf.tabs.alerts'),
|
label: t('waf.tabs.alerts'),
|
||||||
|
|||||||
Reference in New Issue
Block a user