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>
284 lines
8.8 KiB
Go
284 lines
8.8 KiB
Go
// Package waf implements CRUD for per-domain WAF policies (waf_configs).
|
|
package waf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("waf config not found")
|
|
|
|
type Repo struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
|
|
|
const baseSelect = `
|
|
SELECT id, domain_id, enabled, mode, paranoia_level,
|
|
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
|
FROM waf_configs
|
|
`
|
|
|
|
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.AppProfiles, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.ExclusionNotes == nil {
|
|
c.ExclusionNotes = map[string]string{}
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// List returns all WAF configs ordered by domain_id.
|
|
func (r *Repo) List(ctx context.Context) ([]models.WafConfig, error) {
|
|
rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain_id ASC")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.WafConfig, 0, 16)
|
|
for rows.Next() {
|
|
c, err := scan(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetByDomain returns the WAF config for a domain, or ErrNotFound.
|
|
func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConfig, error) {
|
|
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain_id = $1", domainID)
|
|
c, err := scan(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Upsert inserts or updates the WAF config for a domain.
|
|
// Returns the resulting row.
|
|
func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) {
|
|
c.UpdatedAt = time.Now()
|
|
if c.ExclusionNotes == nil {
|
|
c.ExclusionNotes = map[string]string{}
|
|
}
|
|
row := r.Pool.QueryRow(ctx, `
|
|
INSERT INTO waf_configs
|
|
(domain_id, enabled, mode, paranoia_level,
|
|
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, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
|
`,
|
|
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
|
|
c.RuleExclusions, c.CRSPlugins, c.AppProfiles, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
|
|
)
|
|
return scan(row)
|
|
}
|
|
|
|
// ListEnabled returns only configs with enabled=true (used by the WAF agent).
|
|
func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) {
|
|
rows, err := r.Pool.Query(ctx, baseSelect+" WHERE enabled = true ORDER BY domain_id ASC")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.WafConfig, 0, 8)
|
|
for rows.Next() {
|
|
c, err := scan(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// WafAlert mirrors the waf_alerts DB row.
|
|
type WafAlert struct {
|
|
ID int64 `json:"id"`
|
|
DomainID *int64 `json:"domain_id,omitempty"`
|
|
Hostname string `json:"hostname"`
|
|
ClientIP string `json:"client_ip"`
|
|
Method string `json:"method"`
|
|
URI string `json:"uri"`
|
|
RuleID int `json:"rule_id"`
|
|
RuleMsg string `json:"rule_msg"`
|
|
Severity string `json:"severity"`
|
|
Action string `json:"action"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ListAlerts returns recent WAF alerts, optionally filtered by domain_id.
|
|
func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]WafAlert, error) {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 200
|
|
}
|
|
var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error }
|
|
var err error
|
|
if domainID != nil {
|
|
rows2, e := r.Pool.Query(ctx, `
|
|
SELECT id, domain_id, hostname, client_ip, method, uri,
|
|
rule_id, rule_msg, severity, action, created_at
|
|
FROM waf_alerts
|
|
WHERE domain_id = $1
|
|
ORDER BY created_at DESC LIMIT $2
|
|
`, *domainID, limit)
|
|
rows, err = rows2, e
|
|
} else {
|
|
rows2, e := r.Pool.Query(ctx, `
|
|
SELECT id, domain_id, hostname, client_ip, method, uri,
|
|
rule_id, rule_msg, severity, action, created_at
|
|
FROM waf_alerts
|
|
ORDER BY created_at DESC LIMIT $1
|
|
`, limit)
|
|
rows, err = rows2, e
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]WafAlert, 0, limit)
|
|
for rows.Next() {
|
|
var a WafAlert
|
|
if err := rows.Scan(
|
|
&a.ID, &a.DomainID, &a.Hostname, &a.ClientIP, &a.Method, &a.URI,
|
|
&a.RuleID, &a.RuleMsg, &a.Severity, &a.Action, &a.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PurgeAlerts removes alerts older than the given number of days.
|
|
//
|
|
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der
|
|
// frühere ($1 || ' days')::interval-Ansatz erzwang $1 als text; pgx
|
|
// bekam aber einen int und scheiterte mit einem Encode-Fehler zur
|
|
// Laufzeit (gleiche Klasse wie der audit-Cleanup-Bug, v1.3.0).
|
|
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
|
|
_, err := r.Pool.Exec(ctx,
|
|
`DELETE FROM waf_alerts WHERE created_at < NOW() - make_interval(days => $1)`,
|
|
olderThanDays,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// DomainConfigPair combines a domain hostname with its WAF config.
|
|
type DomainConfigPair struct {
|
|
Hostname string
|
|
Config models.WafConfig
|
|
}
|
|
|
|
// 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.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
|
|
ORDER BY d.name ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]DomainConfigPair, 0, 16)
|
|
for rows.Next() {
|
|
var p DomainConfigPair
|
|
var c models.WafConfig
|
|
if err := rows.Scan(
|
|
&p.Hostname,
|
|
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
|
|
&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
|
|
}
|