Files
Charon/backend/internal/services/security_service.go
GitHub Actions 34347b1ff5 Refactor uptime service and tests; add WAF configuration UI and e2e tests
- Refactored `SyncMonitors` method in `uptime_service.go` for better readability.
- Updated unit tests for `UptimeService` to ensure proper functionality.
- Introduced Playwright configuration for end-to-end testing.
- Added e2e tests for WAF blocking and monitoring functionality.
- Enhanced the Security page to include WAF mode and rule set selection.
- Implemented tests for WAF configuration changes and validation.
- Created a `.last-run.json` file to store test results.
2025-12-02 02:51:50 +00:00

251 lines
6.8 KiB
Go

package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/Wikid82/charon/backend/internal/models"
"gorm.io/gorm"
)
var (
ErrSecurityConfigNotFound = errors.New("security config not found")
ErrInvalidAdminCIDR = errors.New("invalid admin whitelist CIDR")
ErrBreakGlassInvalid = errors.New("break-glass token invalid")
)
type SecurityService struct {
db *gorm.DB
}
// NewSecurityService returns a SecurityService using the provided DB
func NewSecurityService(db *gorm.DB) *SecurityService {
return &SecurityService{db: db}
}
// Get returns the first SecurityConfig row (singleton config)
func (s *SecurityService) Get() (*models.SecurityConfig, error) {
var cfg models.SecurityConfig
if err := s.db.First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSecurityConfigNotFound
}
return nil, err
}
return &cfg, nil
}
// Upsert validates and saves a security config
func (s *SecurityService) Upsert(cfg *models.SecurityConfig) error {
// Validate AdminWhitelist - comma-separated list of CIDRs
if cfg.AdminWhitelist != "" {
parts := strings.Split(cfg.AdminWhitelist, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// Validate as IP or CIDR using the same helper as AccessListService
if !isValidCIDR(p) {
return ErrInvalidAdminCIDR
}
}
}
// If a breakglass token is present in BreakGlassHash as empty string,
// do not overwrite it here. Token generation should be done explicitly.
// Validate CrowdSec mode on input prior to any DB operations: only 'local' or 'disabled' supported
if cfg.CrowdSecMode != "" && cfg.CrowdSecMode != "local" && cfg.CrowdSecMode != "disabled" {
return fmt.Errorf("invalid crowdsec mode: %s", cfg.CrowdSecMode)
}
// Upsert behaviour: try to find existing record
var existing models.SecurityConfig
if err := s.db.Where("name = ?", cfg.Name).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// New record
return s.db.Create(cfg).Error
}
return err
}
// Preserve existing BreakGlassHash if not provided
if cfg.BreakGlassHash == "" {
cfg.BreakGlassHash = existing.BreakGlassHash
}
existing.Enabled = cfg.Enabled
existing.AdminWhitelist = cfg.AdminWhitelist
// Validate CrowdSec mode: only 'local' or 'disabled' supported. Reject external/remote values.
if cfg.CrowdSecMode != "" && cfg.CrowdSecMode != "local" && cfg.CrowdSecMode != "disabled" {
return fmt.Errorf("invalid crowdsec mode: %s", cfg.CrowdSecMode)
}
existing.CrowdSecMode = cfg.CrowdSecMode
existing.WAFMode = cfg.WAFMode
existing.RateLimitEnable = cfg.RateLimitEnable
existing.RateLimitBurst = cfg.RateLimitBurst
return s.db.Save(&existing).Error
}
// GenerateBreakGlassToken generates a token, stores its bcrypt hash, and returns the plaintext token
func (s *SecurityService) GenerateBreakGlassToken(name string) (string, error) {
tokenBytes := make([]byte, 24)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
token := hex.EncodeToString(tokenBytes)
hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost)
if err != nil {
return "", err
}
var cfg models.SecurityConfig
if err := s.db.Where("name = ?", name).First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
cfg = models.SecurityConfig{Name: name, BreakGlassHash: string(hash)}
if err := s.db.Create(&cfg).Error; err != nil {
return "", err
}
return token, nil
}
return "", err
}
cfg.BreakGlassHash = string(hash)
if err := s.db.Save(&cfg).Error; err != nil {
return "", err
}
return token, nil
}
// VerifyBreakGlassToken validates a provided token against the stored hash
func (s *SecurityService) VerifyBreakGlassToken(name, token string) (bool, error) {
var cfg models.SecurityConfig
if err := s.db.Where("name = ?", name).First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, ErrSecurityConfigNotFound
}
return false, err
}
if cfg.BreakGlassHash == "" {
return false, ErrBreakGlassInvalid
}
if err := bcrypt.CompareHashAndPassword([]byte(cfg.BreakGlassHash), []byte(token)); err != nil {
return false, ErrBreakGlassInvalid
}
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
}
// Basic validations
if r.Name == "" {
return fmt.Errorf("rule set name required")
}
// Prevent huge payloads from being stored in DB (e.g., limit 2MB)
if len(r.Content) > 2*1024*1024 {
return fmt.Errorf("ruleset content too large")
}
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
}
// DeleteRuleSet removes a ruleset by id
func (s *SecurityService) DeleteRuleSet(id uint) error {
var rs models.SecurityRuleSet
if err := s.db.First(&rs, id).Error; err != nil {
return err
}
return s.db.Delete(&rs).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
if ip := net.ParseIP(cidr); ip != nil {
return true
}
// Try parsing as CIDR
_, _, err := net.ParseCIDR(cidr)
return err == nil
}