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:
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 = `
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user