736 lines
25 KiB
Go
736 lines
25 KiB
Go
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 err := tx.Create(&provider).Error; err != nil {
|
|
return fmt.Errorf("create managed provider: %w", err)
|
|
}
|
|
} 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 err := tx.Save(&provider).Error; err != nil {
|
|
return fmt.Errorf("update managed provider: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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 err := tx.Where("key = ?", newMarkerSetting.Key).First(&markerSetting).Error; err == 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.
|
|
func (s *EnhancedSecurityNotificationService) dispatchToProvider(ctx context.Context, provider models.NotificationProvider, event models.SecurityEvent) error {
|
|
// For now, only webhook-like providers are supported
|
|
// Future: extend with provider-specific dispatch logic (Discord, Slack formatting, etc.)
|
|
switch provider.Type {
|
|
case "webhook", "discord", "slack":
|
|
return s.sendWebhook(ctx, provider.URL, event)
|
|
case "gotify":
|
|
// Gotify requires token-based authentication
|
|
return s.sendGotify(ctx, provider.URL, provider.Token, event)
|
|
default:
|
|
return fmt.Errorf("unsupported provider type: %s", provider.Type)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
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
|
|
}
|
|
|
|
// sendGotify sends a security event to Gotify with token authentication.
|
|
// Blocker 4: SSRF-safe URL validation before outbound requests.
|
|
func (s *EnhancedSecurityNotificationService) sendGotify(ctx context.Context, gotifyURL, token string, event models.SecurityEvent) error {
|
|
// Blocker 4: Validate URL before making outbound request (SSRF protection)
|
|
validatedURL, err := security.ValidateExternalURL(gotifyURL,
|
|
security.WithAllowHTTP(), // Allow HTTP for backwards compatibility
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("ssrf validation failed: %w", err)
|
|
}
|
|
|
|
// Gotify API format: POST /message with token param
|
|
type GotifyMessage struct {
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
Priority int `json:"priority"`
|
|
Extras map[string]interface{} `json:"extras,omitempty"`
|
|
}
|
|
|
|
// Map severity to Gotify priority (0-10)
|
|
priority := 5
|
|
switch event.Severity {
|
|
case "error":
|
|
priority = 8
|
|
case "warn":
|
|
priority = 5
|
|
case "info":
|
|
priority = 3
|
|
case "debug":
|
|
priority = 1
|
|
}
|
|
|
|
msg := GotifyMessage{
|
|
Title: fmt.Sprintf("Security Alert: %s", event.EventType),
|
|
Message: fmt.Sprintf("%s from %s at %s", event.Message, event.ClientIP, event.Path),
|
|
Priority: priority,
|
|
Extras: map[string]interface{}{
|
|
"client_ip": event.ClientIP,
|
|
"path": event.Path,
|
|
"event_type": event.EventType,
|
|
"metadata": event.Metadata,
|
|
},
|
|
}
|
|
|
|
payload, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal gotify message: %w", err)
|
|
}
|
|
|
|
// Gotify expects token as query parameter
|
|
url := fmt.Sprintf("%s/message?token=%s", validatedURL, token)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("create gotify 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 gotify request: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("gotify 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"
|
|
}
|