- 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.
251 lines
6.8 KiB
Go
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
|
|
}
|