chore: git cache cleanup
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/security"
|
||||
"github.com/Wikid82/charon/backend/internal/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EnhancedSecurityNotificationService provides provider-based security notifications with compatibility layer.
|
||||
type EnhancedSecurityNotificationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewEnhancedSecurityNotificationService creates a new enhanced service instance.
|
||||
func NewEnhancedSecurityNotificationService(db *gorm.DB) *EnhancedSecurityNotificationService {
|
||||
return &EnhancedSecurityNotificationService{db: db}
|
||||
}
|
||||
|
||||
// CompatibilitySettings represents the compatibility GET/PUT structure.
|
||||
type CompatibilitySettings struct {
|
||||
SecurityWAFEnabled bool `json:"security_waf_enabled"`
|
||||
SecurityACLEnabled bool `json:"security_acl_enabled"`
|
||||
SecurityRateLimitEnabled bool `json:"security_rate_limit_enabled"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
DestinationAmbiguous bool `json:"destination_ambiguous,omitempty"`
|
||||
WebhookURL string `json:"webhook_url,omitempty"`
|
||||
DiscordWebhookURL string `json:"discord_webhook_url,omitempty"`
|
||||
SlackWebhookURL string `json:"slack_webhook_url,omitempty"`
|
||||
GotifyURL string `json:"gotify_url,omitempty"`
|
||||
GotifyToken string `json:"-"` // Security: Never expose token in JSON (OWASP A02)
|
||||
}
|
||||
|
||||
// MigrationMarker represents the migration state stored in settings table.
|
||||
type MigrationMarker struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
LastCompletedAt string `json:"last_completed_at"`
|
||||
Result string `json:"result"` // completed | completed_with_warnings
|
||||
}
|
||||
|
||||
// GetSettings retrieves compatibility settings via provider aggregation (Spec Section 2).
|
||||
func (s *EnhancedSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) {
|
||||
// Check feature flag
|
||||
enabled, err := s.isFeatureEnabled()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check feature flag: %w", err)
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
// Feature disabled: return legacy config
|
||||
return s.getLegacyConfig()
|
||||
}
|
||||
|
||||
// Feature enabled: aggregate from providers
|
||||
return s.getProviderAggregatedConfig()
|
||||
}
|
||||
|
||||
// getProviderAggregatedConfig aggregates settings from active providers using OR semantics.
|
||||
// Blocker 2: Returns proper compatibility contract with security_* fields.
|
||||
// Blocker 3: Filters enabled=true AND supported notify-only provider types.
|
||||
// Note: This is the GET aggregation path, not the dispatch path. All provider types are included
|
||||
// for configuration visibility. Discord-only enforcement applies to SendViaProviders (dispatch path).
|
||||
func (s *EnhancedSecurityNotificationService) getProviderAggregatedConfig() (*models.NotificationConfig, error) {
|
||||
var providers []models.NotificationProvider
|
||||
err := s.db.Where("enabled = ?", true).Find(&providers).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query providers: %w", err)
|
||||
}
|
||||
|
||||
// Blocker 3: Filter for supported notify-only provider types (PR-1 scope)
|
||||
// All supported types are included in GET aggregation for configuration visibility
|
||||
supportedTypes := map[string]bool{
|
||||
"webhook": true,
|
||||
"discord": true,
|
||||
"slack": true,
|
||||
"gotify": true,
|
||||
}
|
||||
filteredProviders := []models.NotificationProvider{}
|
||||
for _, p := range providers {
|
||||
if supportedTypes[p.Type] {
|
||||
filteredProviders = append(filteredProviders, p)
|
||||
}
|
||||
}
|
||||
|
||||
// OR aggregation: if ANY provider has true, result is true
|
||||
config := &models.NotificationConfig{
|
||||
NotifyWAFBlocks: false,
|
||||
NotifyACLDenies: false,
|
||||
NotifyRateLimitHits: false,
|
||||
NotifyCrowdSecDecisions: false,
|
||||
}
|
||||
|
||||
for _, p := range filteredProviders {
|
||||
if p.NotifySecurityWAFBlocks {
|
||||
config.NotifyWAFBlocks = true
|
||||
}
|
||||
if p.NotifySecurityACLDenies {
|
||||
config.NotifyACLDenies = true
|
||||
}
|
||||
if p.NotifySecurityRateLimitHits {
|
||||
config.NotifyRateLimitHits = true
|
||||
}
|
||||
if p.NotifySecurityCrowdSecDecisions {
|
||||
config.NotifyCrowdSecDecisions = true
|
||||
}
|
||||
}
|
||||
|
||||
// Destination reporting: only if exactly one managed provider exists
|
||||
managedProviders := []models.NotificationProvider{}
|
||||
for _, p := range filteredProviders {
|
||||
if p.ManagedLegacySecurity {
|
||||
managedProviders = append(managedProviders, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(managedProviders) == 1 {
|
||||
// Exactly one managed provider - report destination based on type
|
||||
p := managedProviders[0]
|
||||
switch p.Type {
|
||||
case "webhook":
|
||||
config.WebhookURL = p.URL
|
||||
case "discord":
|
||||
config.DiscordWebhookURL = p.URL
|
||||
case "slack":
|
||||
config.SlackWebhookURL = p.URL
|
||||
case "gotify":
|
||||
config.GotifyURL = p.URL
|
||||
// Blocker 2: Never expose gotify token in compatibility GET responses
|
||||
// Token remains in DB but is not returned to client
|
||||
}
|
||||
config.DestinationAmbiguous = false
|
||||
} else {
|
||||
// Zero or multiple managed providers = ambiguous
|
||||
config.DestinationAmbiguous = true
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// getLegacyConfig retrieves settings from the legacy notification_configs table.
|
||||
func (s *EnhancedSecurityNotificationService) getLegacyConfig() (*models.NotificationConfig, error) {
|
||||
var config models.NotificationConfig
|
||||
err := s.db.First(&config).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return &models.NotificationConfig{
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
NotifyRateLimitHits: true,
|
||||
}, nil
|
||||
}
|
||||
return &config, err
|
||||
}
|
||||
|
||||
// UpdateSettings updates security notification settings via managed provider set (Spec Section 3).
|
||||
func (s *EnhancedSecurityNotificationService) UpdateSettings(req *models.NotificationConfig) error {
|
||||
// Check feature flag
|
||||
enabled, err := s.isFeatureEnabled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check feature flag: %w", err)
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
// Feature disabled: update legacy config
|
||||
return s.updateLegacyConfig(req)
|
||||
}
|
||||
|
||||
// Feature enabled: update via managed provider set
|
||||
return s.updateManagedProviders(req)
|
||||
}
|
||||
|
||||
// updateManagedProviders updates the managed provider set with replace semantics.
|
||||
// Blocker 4: Complete gotify validation - requires both URL and token, reject incomplete with 422.
|
||||
func (s *EnhancedSecurityNotificationService) updateManagedProviders(req *models.NotificationConfig) error {
|
||||
// Validate destination mapping (Spec Section 5: fail-safe handling)
|
||||
destCount := 0
|
||||
var destType string
|
||||
|
||||
if req.WebhookURL != "" {
|
||||
destCount++
|
||||
destType = "webhook"
|
||||
}
|
||||
if req.DiscordWebhookURL != "" {
|
||||
destCount++
|
||||
destType = "discord"
|
||||
}
|
||||
if req.SlackWebhookURL != "" {
|
||||
destCount++
|
||||
destType = "slack"
|
||||
}
|
||||
// Blocker 4: Validate gotify requires BOTH url and token
|
||||
if req.GotifyURL != "" || req.GotifyToken != "" {
|
||||
destCount++
|
||||
destType = "gotify"
|
||||
// Reject incomplete gotify payload with 422 and no mutation
|
||||
if req.GotifyURL == "" || req.GotifyToken == "" {
|
||||
return fmt.Errorf("incomplete gotify configuration: both gotify_url and gotify_token are required")
|
||||
}
|
||||
}
|
||||
|
||||
if destCount > 1 {
|
||||
return fmt.Errorf("ambiguous destination: multiple destination types provided")
|
||||
}
|
||||
|
||||
// Resolve deterministic target set (Spec Section 3: deterministic conflict behavior)
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var managedProviders []models.NotificationProvider
|
||||
err := tx.Where("managed_legacy_security = ?", true).Find(&managedProviders).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("query managed providers: %w", err)
|
||||
}
|
||||
|
||||
// Blocker 4: Deterministic target set allows one-or-more managed providers
|
||||
// Update full managed set; only 409 on true non-resolvable identity corruption
|
||||
// Multiple managed providers ARE the valid target set (not corruption)
|
||||
|
||||
if len(managedProviders) == 0 {
|
||||
// Create managed provider
|
||||
provider := &models.NotificationProvider{
|
||||
Name: "Migrated Security Notifications (Legacy)",
|
||||
Type: destType,
|
||||
Enabled: true,
|
||||
ManagedLegacySecurity: true,
|
||||
NotifySecurityWAFBlocks: req.NotifyWAFBlocks,
|
||||
NotifySecurityACLDenies: req.NotifyACLDenies,
|
||||
NotifySecurityRateLimitHits: req.NotifyRateLimitHits,
|
||||
URL: s.extractDestinationURL(req),
|
||||
Token: s.extractDestinationToken(req),
|
||||
}
|
||||
return tx.Create(provider).Error
|
||||
}
|
||||
|
||||
// Blocker 3: Enforce PUT idempotency - only save if values actually changed
|
||||
// Update all managed providers with replace semantics
|
||||
for i := range managedProviders {
|
||||
changed := false
|
||||
|
||||
// Check if security event flags changed
|
||||
if managedProviders[i].NotifySecurityWAFBlocks != req.NotifyWAFBlocks {
|
||||
managedProviders[i].NotifySecurityWAFBlocks = req.NotifyWAFBlocks
|
||||
changed = true
|
||||
}
|
||||
if managedProviders[i].NotifySecurityACLDenies != req.NotifyACLDenies {
|
||||
managedProviders[i].NotifySecurityACLDenies = req.NotifyACLDenies
|
||||
changed = true
|
||||
}
|
||||
if managedProviders[i].NotifySecurityRateLimitHits != req.NotifyRateLimitHits {
|
||||
managedProviders[i].NotifySecurityRateLimitHits = req.NotifyRateLimitHits
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Update destination if provided
|
||||
if destURL := s.extractDestinationURL(req); destURL != "" {
|
||||
if managedProviders[i].URL != destURL {
|
||||
managedProviders[i].URL = destURL
|
||||
changed = true
|
||||
}
|
||||
if managedProviders[i].Type != destType {
|
||||
managedProviders[i].Type = destType
|
||||
changed = true
|
||||
}
|
||||
if managedProviders[i].Token != s.extractDestinationToken(req) {
|
||||
managedProviders[i].Token = s.extractDestinationToken(req)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Blocker 3: Only save (update timestamps) if values actually changed
|
||||
if changed {
|
||||
if err := tx.Save(&managedProviders[i]).Error; err != nil {
|
||||
return fmt.Errorf("update provider %s: %w", managedProviders[i].ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// extractDestinationURL extracts the destination URL from the request.
|
||||
func (s *EnhancedSecurityNotificationService) extractDestinationURL(req *models.NotificationConfig) string {
|
||||
if req.WebhookURL != "" {
|
||||
return req.WebhookURL
|
||||
}
|
||||
if req.DiscordWebhookURL != "" {
|
||||
return req.DiscordWebhookURL
|
||||
}
|
||||
if req.SlackWebhookURL != "" {
|
||||
return req.SlackWebhookURL
|
||||
}
|
||||
if req.GotifyURL != "" {
|
||||
return req.GotifyURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractDestinationToken extracts the auth token from the request (currently only gotify).
|
||||
func (s *EnhancedSecurityNotificationService) extractDestinationToken(req *models.NotificationConfig) string {
|
||||
if req.GotifyToken != "" {
|
||||
return req.GotifyToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// updateLegacyConfig updates the legacy notification_configs table.
|
||||
func (s *EnhancedSecurityNotificationService) updateLegacyConfig(req *models.NotificationConfig) error {
|
||||
var existing models.NotificationConfig
|
||||
err := s.db.First(&existing).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return s.db.Create(req).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch existing config: %w", err)
|
||||
}
|
||||
|
||||
req.ID = existing.ID
|
||||
return s.db.Save(req).Error
|
||||
}
|
||||
|
||||
// MigrateFromLegacyConfig performs deterministic migration from legacy config to managed provider (Spec Section 4).
|
||||
// Blocker 2: Respects feature flag - does NOT mutate providers when flag=false.
|
||||
func (s *EnhancedSecurityNotificationService) MigrateFromLegacyConfig() error {
|
||||
// Check feature flag first
|
||||
enabled, err := s.isFeatureEnabled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check feature flag: %w", err)
|
||||
}
|
||||
|
||||
// Read legacy config
|
||||
var legacyConfig models.NotificationConfig
|
||||
err = s.db.First(&legacyConfig).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// No legacy config to migrate
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read legacy config: %w", err)
|
||||
}
|
||||
|
||||
// Compute checksum
|
||||
checksum := computeConfigChecksum(legacyConfig)
|
||||
|
||||
// Read migration marker
|
||||
var markerSetting models.Setting
|
||||
err = s.db.Where("key = ?", "notifications.security_provider_events.migration.v1").First(&markerSetting).Error
|
||||
|
||||
if err == nil {
|
||||
// Marker exists - check if checksum matches
|
||||
var marker MigrationMarker
|
||||
if err := json.Unmarshal([]byte(markerSetting.Value), &marker); err != nil {
|
||||
logger.Log().WithError(err).Warn("Failed to unmarshal migration marker")
|
||||
} else if marker.Checksum == checksum {
|
||||
// Checksum matches - no-op
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If feature flag is disabled, perform dry-evaluate only (no mutation)
|
||||
if !enabled {
|
||||
logger.Log().Info("Feature flag disabled - migration runs in read-only mode (no provider mutation)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform migration in transaction
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Upsert managed provider
|
||||
var provider models.NotificationProvider
|
||||
err := tx.Where("managed_legacy_security = ?", true).First(&provider).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new managed provider
|
||||
provider = models.NotificationProvider{
|
||||
Name: "Migrated Security Notifications (Legacy)",
|
||||
Type: "webhook",
|
||||
Enabled: true,
|
||||
ManagedLegacySecurity: true,
|
||||
NotifySecurityWAFBlocks: legacyConfig.NotifyWAFBlocks,
|
||||
NotifySecurityACLDenies: legacyConfig.NotifyACLDenies,
|
||||
NotifySecurityRateLimitHits: legacyConfig.NotifyRateLimitHits,
|
||||
URL: legacyConfig.WebhookURL,
|
||||
}
|
||||
if createErr := tx.Create(&provider).Error; createErr != nil {
|
||||
return fmt.Errorf("create managed provider: %w", createErr)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("query managed provider: %w", err)
|
||||
} else {
|
||||
// Update existing managed provider
|
||||
provider.NotifySecurityWAFBlocks = legacyConfig.NotifyWAFBlocks
|
||||
provider.NotifySecurityACLDenies = legacyConfig.NotifyACLDenies
|
||||
provider.NotifySecurityRateLimitHits = legacyConfig.NotifyRateLimitHits
|
||||
provider.URL = legacyConfig.WebhookURL
|
||||
if saveErr := tx.Save(&provider).Error; saveErr != nil {
|
||||
return fmt.Errorf("update managed provider: %w", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Write migration marker
|
||||
marker := MigrationMarker{
|
||||
Version: "v1",
|
||||
Checksum: checksum,
|
||||
LastCompletedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Result: "completed",
|
||||
}
|
||||
markerJSON, err := json.Marshal(marker)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal marker: %w", err)
|
||||
}
|
||||
|
||||
newMarkerSetting := models.Setting{
|
||||
Key: "notifications.security_provider_events.migration.v1",
|
||||
Value: string(markerJSON),
|
||||
Type: "json",
|
||||
Category: "notifications",
|
||||
}
|
||||
|
||||
// Upsert marker
|
||||
if queryErr := tx.Where("key = ?", newMarkerSetting.Key).First(&markerSetting).Error; queryErr == gorm.ErrRecordNotFound {
|
||||
return tx.Create(&newMarkerSetting).Error
|
||||
}
|
||||
newMarkerSetting.ID = markerSetting.ID
|
||||
return tx.Save(&newMarkerSetting).Error
|
||||
})
|
||||
}
|
||||
|
||||
// computeConfigChecksum computes a deterministic checksum from legacy config fields.
|
||||
func computeConfigChecksum(config models.NotificationConfig) string {
|
||||
// Create deterministic string representation
|
||||
fields := []string{
|
||||
fmt.Sprintf("waf:%t", config.NotifyWAFBlocks),
|
||||
fmt.Sprintf("acl:%t", config.NotifyACLDenies),
|
||||
fmt.Sprintf("rate:%t", config.NotifyRateLimitHits),
|
||||
fmt.Sprintf("url:%s", config.WebhookURL),
|
||||
}
|
||||
sort.Strings(fields) // Ensure field order doesn't affect checksum
|
||||
|
||||
data := ""
|
||||
for _, f := range fields {
|
||||
data += f + "|"
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// isFeatureEnabled checks the feature flag in settings table (Spec Section 6).
|
||||
func (s *EnhancedSecurityNotificationService) isFeatureEnabled() (bool, error) {
|
||||
var setting models.Setting
|
||||
err := s.db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Blocker 5: Implement feature flag defaults exactly as per spec
|
||||
// Initialize based on environment detection
|
||||
defaultValue := s.getDefaultFeatureFlagValue()
|
||||
|
||||
// Create the setting with appropriate default
|
||||
newSetting := models.Setting{
|
||||
Key: "feature.notifications.security_provider_events.enabled",
|
||||
Value: defaultValue,
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}
|
||||
|
||||
if createErr := s.db.Create(&newSetting).Error; createErr != nil {
|
||||
// If creation fails (e.g., race condition), re-query
|
||||
if queryErr := s.db.Where("key = ?", newSetting.Key).First(&setting).Error; queryErr != nil {
|
||||
return defaultValue == "true", fmt.Errorf("create and requery feature flag: %w", queryErr)
|
||||
}
|
||||
return setting.Value == "true", nil
|
||||
}
|
||||
|
||||
return defaultValue == "true", nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("query feature flag: %w", err)
|
||||
}
|
||||
|
||||
return setting.Value == "true", nil
|
||||
}
|
||||
|
||||
// SendViaProviders dispatches security events to active providers.
|
||||
// When feature flag is enabled, this is the authoritative dispatch path.
|
||||
// Blocker 3: Discord-only enforcement for rollout - only Discord providers receive security events.
|
||||
// Server-side guarantee holds for existing rows and all dispatch paths.
|
||||
func (s *EnhancedSecurityNotificationService) SendViaProviders(ctx context.Context, event models.SecurityEvent) error {
|
||||
// Query active providers that have the relevant event type enabled
|
||||
var providers []models.NotificationProvider
|
||||
err := s.db.Where("enabled = ?", true).Find(&providers).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("query providers: %w", err)
|
||||
}
|
||||
|
||||
// Blocker 3: Discord-only enforcement for rollout stage
|
||||
// ONLY Discord providers are allowed to receive security events
|
||||
// This is a server-side guarantee that prevents any non-Discord provider
|
||||
// from receiving security notifications, even if flags are enabled in DB
|
||||
supportedTypes := map[string]bool{
|
||||
"discord": true,
|
||||
// webhook, slack, gotify explicitly excluded for rollout
|
||||
}
|
||||
|
||||
// Filter providers based on event type AND supported type
|
||||
var targetProviders []models.NotificationProvider
|
||||
for _, p := range providers {
|
||||
if !supportedTypes[p.Type] {
|
||||
continue
|
||||
}
|
||||
shouldNotify := false
|
||||
// Normalize event type to handle variations
|
||||
normalizedEventType := normalizeSecurityEventType(event.EventType)
|
||||
switch normalizedEventType {
|
||||
case "waf_block":
|
||||
shouldNotify = p.NotifySecurityWAFBlocks
|
||||
case "acl_deny":
|
||||
shouldNotify = p.NotifySecurityACLDenies
|
||||
case "rate_limit":
|
||||
shouldNotify = p.NotifySecurityRateLimitHits
|
||||
case "crowdsec_decision":
|
||||
shouldNotify = p.NotifySecurityCrowdSecDecisions
|
||||
}
|
||||
if shouldNotify {
|
||||
targetProviders = append(targetProviders, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetProviders) == 0 {
|
||||
// No providers configured for this event type - fail closed (no notification)
|
||||
logger.Log().WithField("event_type", util.SanitizeForLog(event.EventType)).Debug("No providers configured for security event")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dispatch to all target providers (best-effort, log failures but don't block)
|
||||
for _, p := range targetProviders {
|
||||
if err := s.dispatchToProvider(ctx, p, event); err != nil {
|
||||
logger.Log().WithError(err).WithField("provider_id", p.ID).Error("Failed to dispatch to provider")
|
||||
// Continue to next provider (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatchToProvider sends the event to a single provider.
|
||||
// Discord-only enforcement: rejects all non-Discord providers in this rollout.
|
||||
func (s *EnhancedSecurityNotificationService) dispatchToProvider(ctx context.Context, provider models.NotificationProvider, event models.SecurityEvent) error {
|
||||
// Discord-only enforcement for rollout: reject non-Discord types explicitly
|
||||
if provider.Type != "discord" {
|
||||
return fmt.Errorf("discord-only rollout: provider type %q is not supported; only discord is enabled", provider.Type)
|
||||
}
|
||||
// Discord dispatch via webhook
|
||||
return s.sendWebhook(ctx, provider.URL, event)
|
||||
}
|
||||
|
||||
// sendWebhook sends a security event to a webhook URL (shared with legacy service).
|
||||
// Blocker 4: SSRF-safe URL validation before outbound requests.
|
||||
func (s *EnhancedSecurityNotificationService) sendWebhook(ctx context.Context, webhookURL string, event models.SecurityEvent) error {
|
||||
// Blocker 4: Validate URL before making outbound request (SSRF protection)
|
||||
validatedURL, err := security.ValidateExternalURL(webhookURL,
|
||||
security.WithAllowHTTP(), // Allow HTTP for backwards compatibility
|
||||
security.WithAllowLocalhost(), // Allow localhost for testing
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssrf validation failed: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", validatedURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Charon-Cerberus/1.0")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecurityEventType normalizes event type variations to canonical forms.
|
||||
func normalizeSecurityEventType(eventType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(eventType))
|
||||
|
||||
// Map variations to canonical forms
|
||||
switch {
|
||||
case strings.Contains(normalized, "waf"):
|
||||
return "waf_block"
|
||||
case strings.Contains(normalized, "acl"):
|
||||
return "acl_deny"
|
||||
case strings.Contains(normalized, "rate") && strings.Contains(normalized, "limit"):
|
||||
return "rate_limit"
|
||||
case strings.Contains(normalized, "crowdsec"):
|
||||
return "crowdsec_decision"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultFeatureFlagValue returns default based on environment (Spec Section 6).
|
||||
// Blocker 1: Reliable prod=false, dev/test=true without fragile markers.
|
||||
// Production detection: CHARON_ENV=production OR (CHARON_ENV unset AND GIN_MODE unset)
|
||||
func (s *EnhancedSecurityNotificationService) getDefaultFeatureFlagValue() string {
|
||||
// Explicit production declaration
|
||||
charonEnv := os.Getenv("CHARON_ENV")
|
||||
if charonEnv == "production" || charonEnv == "prod" {
|
||||
return "false" // Production: default disabled
|
||||
}
|
||||
|
||||
// Check if we're in a test environment via test marker (inserted by test setup)
|
||||
var testMarker models.Setting
|
||||
err := s.db.Where("key = ?", "_test_mode_marker").First(&testMarker).Error
|
||||
if err == nil && testMarker.Value == "true" {
|
||||
return "true" // Test environment
|
||||
}
|
||||
|
||||
// Check GIN_MODE for dev/test detection
|
||||
ginMode := os.Getenv("GIN_MODE")
|
||||
if ginMode == "debug" || ginMode == "test" {
|
||||
return "true" // Development/test
|
||||
}
|
||||
|
||||
// Blocker 1 Fix: When both CHARON_ENV and GIN_MODE are unset, assume production
|
||||
// Production systems should be explicit with CHARON_ENV=production, but default to safe (disabled)
|
||||
if charonEnv == "" && ginMode == "" {
|
||||
return "false" // Unset env vars = production default
|
||||
}
|
||||
|
||||
// All other cases: enable for dev/test safety
|
||||
return "true"
|
||||
}
|
||||
Reference in New Issue
Block a user