diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index ab9e97bd..b0b924de 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -3,6 +3,7 @@ package handlers import ( "net" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -145,6 +146,82 @@ func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"token": token}) } +// ListDecisions returns recent security decisions +func (h *SecurityHandler) ListDecisions(c *gin.Context) { + limit := 50 + if q := c.Query("limit"); q != "" { + if v, err := strconv.Atoi(q); err == nil { + limit = v + } + } + list, err := h.svc.ListDecisions(limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"}) + return + } + c.JSON(http.StatusOK, gin.H{"decisions": list}) +} + +// CreateDecision creates a manual decision (override) - for now no checks besides payload +func (h *SecurityHandler) CreateDecision(c *gin.Context) { + var payload models.SecurityDecision + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if payload.IP == "" || payload.Action == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"}) + return + } + // Populate source + payload.Source = "manual" + if err := h.svc.LogDecision(&payload); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"}) + return + } + // Record an audit entry + actor := c.GetString("user_id") + if actor == "" { + actor = c.ClientIP() + } + _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details}) + c.JSON(http.StatusOK, gin.H{"decision": payload}) +} + +// ListRuleSets returns the list of known rulesets +func (h *SecurityHandler) ListRuleSets(c *gin.Context) { + list, err := h.svc.ListRuleSets() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"}) + return + } + c.JSON(http.StatusOK, gin.H{"rulesets": list}) +} + +// UpsertRuleSet uploads or updates a ruleset +func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { + var payload models.SecurityRuleSet + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if payload.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) + return + } + if err := h.svc.UpsertRuleSet(&payload); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"}) + return + } + // Create an audit event + actor := c.GetString("user_id") + if actor == "" { + actor = c.ClientIP() + } + _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name}) + c.JSON(http.StatusOK, gin.H{"ruleset": payload}) +} + // 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 new file mode 100644 index 00000000..cd635e69 --- /dev/null +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) + + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db) + api.POST("/security/decisions", h.CreateDecision) + api.GET("/security/decisions", h.ListDecisions) + api.POST("/security/rulesets", h.UpsertRuleSet) + api.GET("/security/rulesets", h.ListRuleSets) + return r, db +} + +func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) { + r, _ := setupSecurityTestRouterWithExtras(t) + + payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + + var decisionResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp)) + require.NotNil(t, decisionResp["decision"]) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var listResp map[string][]map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp)) + require.GreaterOrEqual(t, len(listResp["decisions"]), 1) + + // Now test ruleset upsert + rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var rsResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp)) + require.NotNil(t, rsResp["ruleset"]) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var listRsResp map[string][]map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp)) + require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c5082321..c9d51931 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -39,7 +39,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.UptimeHost{}, &models.UptimeNotificationEvent{}, &models.Domain{}, - &models.SecurityConfig{}, + &models.SecurityConfig{}, + &models.SecurityDecision{}, + &models.SecurityAudit{}, + &models.SecurityRuleSet{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -214,6 +217,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.POST("/security/enable", securityHandler.Enable) protected.POST("/security/disable", securityHandler.Disable) protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass) + protected.GET("/security/decisions", securityHandler.ListDecisions) + protected.POST("/security/decisions", securityHandler.CreateDecision) + protected.GET("/security/rulesets", securityHandler.ListRuleSets) + protected.POST("/security/rulesets", securityHandler.UpsertRuleSet) // CrowdSec process management and import // Data dir for crowdsec (persisted on host via volumes) diff --git a/backend/internal/models/security_audit.go b/backend/internal/models/security_audit.go new file mode 100644 index 00000000..4eaaba9a --- /dev/null +++ b/backend/internal/models/security_audit.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" +) + +// SecurityAudit records admin actions or important changes related to security. +type SecurityAudit struct { + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Actor string `json:"actor"` + Action string `json:"action"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/models/security_config.go b/backend/internal/models/security_config.go index 94da1682..763d2766 100644 --- a/backend/internal/models/security_config.go +++ b/backend/internal/models/security_config.go @@ -13,10 +13,15 @@ type SecurityConfig struct { Enabled bool `json:"enabled"` AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"` - CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block" + CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block"; also supports "local"/"external" + CrowdSecAPIURL string `json:"crowdsec_api_url" gorm:"type:text"` WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block" + WAFRulesSource string `json:"waf_rules_source" gorm:"type:text"` // URL or name of ruleset + WAFLearning bool `json:"waf_learning"` RateLimitEnable bool `json:"rate_limit_enable"` RateLimitBurst int `json:"rate_limit_burst"` + RateLimitRequests int `json:"rate_limit_requests"` + RateLimitWindowSec int `json:"rate_limit_window_sec"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/models/security_decision.go b/backend/internal/models/security_decision.go new file mode 100644 index 00000000..19689dd6 --- /dev/null +++ b/backend/internal/models/security_decision.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" +) + +// SecurityDecision stores a decision/action taken by CrowdSec/WAF/RateLimit or manual +// override so it can be audited and surfaced in the UI. +type SecurityDecision struct { + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Source string `json:"source"` // e.g., crowdsec, waf, ratelimit, manual + Action string `json:"action"` // allow, block, challenge + IP string `json:"ip"` + Host string `json:"host"` // optional + RuleID string `json:"rule_id"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/models/security_ruleset.go b/backend/internal/models/security_ruleset.go new file mode 100644 index 00000000..9fb75876 --- /dev/null +++ b/backend/internal/models/security_ruleset.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +// SecurityRuleSet stores metadata about WAF/CrowdSec rule sets that the server can download and apply. +type SecurityRuleSet struct { + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + SourceURL string `json:"source_url" gorm:"type:text"` + Mode string `json:"mode"` // optional e.g., 'owasp', 'custom' + LastUpdated time.Time `json:"last_updated"` + Content string `json:"content" gorm:"type:text"` +} diff --git a/backend/internal/services/security_service.go b/backend/internal/services/security_service.go index 41ae7c2c..3f1666af 100644 --- a/backend/internal/services/security_service.go +++ b/backend/internal/services/security_service.go @@ -6,7 +6,9 @@ import ( "errors" "strings" "net" + "time" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "github.com/Wikid82/charon/backend/internal/models" @@ -134,6 +136,82 @@ func (s *SecurityService) VerifyBreakGlassToken(name, token string) (bool, error return true, nil } +// LogDecision stores a security decision record +func (s *SecurityService) LogDecision(d *models.SecurityDecision) error { + if d == nil { + return nil + } + if d.UUID == "" { + d.UUID = uuid.NewString() + } + if d.CreatedAt.IsZero() { + d.CreatedAt = time.Now() + } + return s.db.Create(d).Error +} + +// ListDecisions returns recent security decisions, ordered by created_at desc +func (s *SecurityService) ListDecisions(limit int) ([]models.SecurityDecision, error) { + var res []models.SecurityDecision + q := s.db.Order("created_at desc") + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Find(&res).Error; err != nil { + return nil, err + } + return res, nil +} + +// LogAudit stores an audit entry +func (s *SecurityService) LogAudit(a *models.SecurityAudit) error { + if a == nil { + return nil + } + if a.UUID == "" { + a.UUID = uuid.NewString() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + return s.db.Create(a).Error +} + +// UpsertRuleSet saves or updates a ruleset content +func (s *SecurityService) UpsertRuleSet(r *models.SecurityRuleSet) error { + if r == nil { + return nil + } + var existing models.SecurityRuleSet + if err := s.db.Where("name = ?", r.Name).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if r.UUID == "" { + r.UUID = uuid.NewString() + } + if r.LastUpdated.IsZero() { + r.LastUpdated = time.Now() + } + return s.db.Create(r).Error + } + return err + } + existing.SourceURL = r.SourceURL + existing.Content = r.Content + existing.Mode = r.Mode + existing.LastUpdated = r.LastUpdated + return s.db.Save(&existing).Error +} + + +// ListRuleSets returns all known rulesets +func (s *SecurityService) ListRuleSets() ([]models.SecurityRuleSet, error) { + var res []models.SecurityRuleSet + if err := s.db.Find(&res).Error; err != nil { + return nil, err + } + return res, nil +} + // helper: reused from access_list_service validation for CIDR/IP parsing func isValidCIDR(cidr string) bool { // Try parsing as single IP diff --git a/backend/internal/services/security_service_test.go b/backend/internal/services/security_service_test.go index 11cbc113..a9610e82 100644 --- a/backend/internal/services/security_service_test.go +++ b/backend/internal/services/security_service_test.go @@ -14,7 +14,7 @@ func setupSecurityTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) assert.NoError(t, err) - err = db.AutoMigrate(&models.SecurityConfig{}) + err = db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}) assert.NoError(t, err) return db @@ -64,3 +64,31 @@ func TestSecurityService_BreakGlassTokenLifecycle(t *testing.T) { assert.Error(t, err) assert.False(t, ok) } + +func TestSecurityService_LogDecisionAndList(t *testing.T) { + db := setupSecurityTestDB(t) + svc := NewSecurityService(db) + + dec := &models.SecurityDecision{Source: "manual", Action: "block", IP: "1.2.3.4", Host: "example.com", RuleID: "manual-1", Details: "test manual block"} + err := svc.LogDecision(dec) + assert.NoError(t, err) + + list, err := svc.ListDecisions(10) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + assert.Equal(t, "manual", list[0].Source) +} + +func TestSecurityService_UpsertRuleSet(t *testing.T) { + db := setupSecurityTestDB(t) + svc := NewSecurityService(db) + + rs := &models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "https://example.com/owasp.rules", Mode: "owasp", Content: "rule: 1"} + err := svc.UpsertRuleSet(rs) + assert.NoError(t, err) + + list, err := svc.ListRuleSets() + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + assert.Equal(t, "owasp-crs", list[0].Name) +} diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts index 44101fbd..13dbc02f 100644 --- a/frontend/src/api/security.ts +++ b/frontend/src/api/security.ts @@ -30,9 +30,14 @@ export interface SecurityConfigPayload { enabled?: boolean admin_whitelist?: string crowdsec_mode?: string + crowdsec_api_url?: string waf_mode?: string + waf_rules_source?: string + waf_learning?: boolean rate_limit_enable?: boolean rate_limit_burst?: number + rate_limit_requests?: number + rate_limit_window_sec?: number } export const getSecurityConfig = async () => { @@ -59,3 +64,23 @@ export const disableCerberus = async (payload?: any) => { const response = await client.post('/security/disable', payload || {}) return response.data } + +export const getDecisions = async (limit = 50) => { + const response = await client.get(`/security/decisions?limit=${limit}`) + return response.data +} + +export const createDecision = async (payload: any) => { + const response = await client.post('/security/decisions', payload) + return response.data +} + +export const getRuleSets = async () => { + const response = await client.get('/security/rulesets') + return response.data +} + +export const upsertRuleSet = async (payload: any) => { + const response = await client.post('/security/rulesets', payload) + return response.data +} diff --git a/frontend/src/hooks/useSecurity.ts b/frontend/src/hooks/useSecurity.ts index 423aa491..b7d44db5 100644 --- a/frontend/src/hooks/useSecurity.ts +++ b/frontend/src/hooks/useSecurity.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus } from '../api/security' +import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus, getDecisions, createDecision, getRuleSets, upsertRuleSet } from '../api/security' import toast from 'react-hot-toast' export function useSecurityStatus() { @@ -29,6 +29,30 @@ export function useGenerateBreakGlassToken() { return useMutation({ mutationFn: () => generateBreakGlassToken() }) } +export function useDecisions(limit = 50) { + return useQuery({ queryKey: ['securityDecisions', limit], queryFn: () => getDecisions(limit) }) +} + +export function useCreateDecision() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: any) => createDecision(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['securityDecisions'] }), + }) +} + +export function useRuleSets() { + return useQuery({ queryKey: ['securityRulesets'], queryFn: () => getRuleSets() }) +} + +export function useUpsertRuleSet() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: any) => upsertRuleSet(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['securityRulesets'] }), + }) +} + export function useEnableCerberus() { const qc = useQueryClient() return useMutation({