From f5fb460cc6421f302967b35fb4ef34ec7e182526 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 1 Dec 2025 19:56:15 +0000 Subject: [PATCH] feat(security): add DeleteRuleSet endpoint and implement related service logic --- .../internal/api/handlers/security_handler.go | 31 ++++++++++++++++- .../security_handler_rules_decisions_test.go | 17 +++++++++- backend/internal/api/routes/routes.go | 1 + backend/internal/caddy/config.go | 13 +++---- backend/internal/caddy/manager.go | 3 +- backend/internal/cerberus/cerberus.go | 4 +-- .../cerberus/cerberus_isenabled_test.go | 2 +- .../cerberus/cerberus_middleware_test.go | 4 +-- backend/internal/services/security_service.go | 17 ++++++++++ .../services/security_service_test.go | 34 +++++++++++++++++++ 10 files changed, 112 insertions(+), 14 deletions(-) diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 140ede92..04fe400e 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net" "net/http" "strconv" @@ -91,7 +92,7 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) { }, "waf": gin.H{ "mode": h.cfg.WAFMode, - "enabled": h.cfg.WAFMode == "enabled", + "enabled": h.cfg.WAFMode != "" && h.cfg.WAFMode != "disabled", }, "rate_limit": gin.H{ "mode": h.cfg.RateLimitMode, @@ -221,6 +222,34 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ruleset": payload}) } +// DeleteRuleSet removes a ruleset by id +func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { + idParam := c.Param("id") + if idParam == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.svc.DeleteRuleSet(uint(id)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"}) + return + } + actor := c.GetString("user_id") + if actor == "" { + actor = c.ClientIP() + } + _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam}) + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + // Enable toggles Cerberus on, validating admin whitelist or break-glass token func (h *SecurityHandler) Enable(c *gin.Context) { // Look for requester's IP and optional breakglass token diff --git a/backend/internal/api/handlers/security_handler_rules_decisions_test.go b/backend/internal/api/handlers/security_handler_rules_decisions_test.go index cd635e69..6046ec3e 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "strings" "testing" + "strconv" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -42,7 +43,9 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) { req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() r.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) + if resp.Code != http.StatusOK { + t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) + } var decisionResp map[string]interface{} require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp)) @@ -74,4 +77,16 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) { var listRsResp map[string][]map[string]interface{} require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp)) require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1) + + // Delete the ruleset we just created + idFloat, ok := listRsResp["rulesets"][0]["id"].(float64) + require.True(t, ok) + id := int(idFloat) + req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var delResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp)) + require.Equal(t, true, delResp["deleted"].(bool)) } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c9d51931..c43a8d9c 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -221,6 +221,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.POST("/security/decisions", securityHandler.CreateDecision) protected.GET("/security/rulesets", securityHandler.ListRuleSets) protected.POST("/security/rulesets", securityHandler.UpsertRuleSet) + protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet) // CrowdSec process management and import // Data dir for crowdsec (persisted on host via volumes) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 4ea37815..ced544d3 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -256,11 +256,9 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin securityHandlers = append(securityHandlers, csH) } - // WAF handler (placeholder) - if wafEnabled { - if wafH, err := buildWAFHandler(&host, rulesets, secCfg); err == nil && wafH != nil { - securityHandlers = append(securityHandlers, wafH) - } + // WAF handler (placeholder) — add according to runtime flag + if wafH, err := buildWAFHandler(&host, rulesets, secCfg, wafEnabled); err == nil && wafH != nil { + securityHandlers = append(securityHandlers, wafH) } // Rate Limit handler (placeholder) @@ -703,7 +701,7 @@ func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig, // buildWAFHandler returns a placeholder WAF handler (Coraza) configuration. // This is a stub; integration with a Coraza caddy plugin would be required // for real runtime enforcement. -func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig) (Handler, error) { +func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) { // Find a ruleset to associate with WAF; prefer name match by host.Application or default 'owasp-crs' var selected *models.SecurityRuleSet for i, r := range rulesets { @@ -713,6 +711,9 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, } } + if !wafEnabled { + return nil, nil + } h := Handler{"handler": "coraza"} if selected != nil { h["ruleset_name"] = selected.Name diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index c5cf3ef2..4f7dfbf7 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -275,7 +275,8 @@ func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) { func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool, aclEnabled bool, wafEnabled bool, rateLimitEnabled bool, crowdsecEnabled bool) { // Base flags from static config cerbEnabled = m.securityCfg.CerberusEnabled - wafEnabled = m.securityCfg.WAFMode == "enabled" + // WAF is enabled if explicitly set and not 'disabled' (supports 'monitor'/'block') + wafEnabled = m.securityCfg.WAFMode != "" && m.securityCfg.WAFMode != "disabled" rateLimitEnabled = m.securityCfg.RateLimitMode == "enabled" // CrowdSec only supports 'local' mode; treat other values as disabled crowdsecEnabled = m.securityCfg.CrowdSecMode == "local" diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 8ad60705..4289b657 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -39,7 +39,7 @@ func (c *Cerberus) IsEnabled() bool { if c.cfg.CrowdSecMode == "local" { return true } - if c.cfg.WAFMode == "enabled" || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" { + if (c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled") || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" { return true } @@ -63,7 +63,7 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { } // WAF: naive example check - block requests containing