feat(security): implement decision and ruleset management with logging and retrieval

This commit is contained in:
GitHub Actions
2025-12-01 18:23:15 +00:00
parent 53765afd35
commit 570d904019
11 changed files with 375 additions and 4 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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({