// Package webhook provides outbound SOAR webhook notifications // for the SOC pipeline. Fires HTTP POST on incident creation/update. package soc import ( "bytes" "encoding/json" "fmt" "log/slog" "math/rand/v2" "net/http" "sync" "time" domsoc "github.com/syntrex/gomcp/internal/domain/soc" ) // WebhookConfig holds SOAR webhook settings. type WebhookConfig struct { // Endpoints is a list of webhook URLs to POST to. Endpoints []string `json:"endpoints"` // Headers are custom HTTP headers added to every request (e.g., auth tokens). Headers map[string]string `json:"headers,omitempty"` // MaxRetries is the number of retry attempts on failure (default 3). MaxRetries int `json:"max_retries"` // TimeoutSec is the HTTP client timeout in seconds (default 10). TimeoutSec int `json:"timeout_sec"` // MinSeverity filters: only incidents >= this severity trigger webhooks. // Empty string means all severities. MinSeverity domsoc.EventSeverity `json:"min_severity,omitempty"` } // WebhookPayload is the JSON body sent to SOAR endpoints. type WebhookPayload struct { EventType string `json:"event_type"` // incident_created, incident_updated, sensor_offline Timestamp time.Time `json:"timestamp"` Source string `json:"source"` Data json.RawMessage `json:"data"` } // WebhookResult tracks delivery status per endpoint. type WebhookResult struct { Endpoint string `json:"endpoint"` StatusCode int `json:"status_code"` Success bool `json:"success"` Retries int `json:"retries"` Error string `json:"error,omitempty"` } // WebhookNotifier handles outbound SOAR notifications. type WebhookNotifier struct { mu sync.RWMutex config WebhookConfig client *http.Client enabled bool // Stats (unexported — access via Stats() method) sent int64 failed int64 } // NewWebhookNotifier creates a notifier with the given config. func NewWebhookNotifier(config WebhookConfig) *WebhookNotifier { if config.MaxRetries <= 0 { config.MaxRetries = 3 } timeout := time.Duration(config.TimeoutSec) * time.Second if timeout <= 0 { timeout = 10 * time.Second } return &WebhookNotifier{ config: config, client: &http.Client{Timeout: timeout}, enabled: len(config.Endpoints) > 0, } } // NotifyIncident sends an incident webhook to all configured endpoints. // Non-blocking: fires goroutines for each endpoint. func (w *WebhookNotifier) NotifyIncident(eventType string, incident *domsoc.Incident) []WebhookResult { if !w.enabled || incident == nil { return nil } // Severity filter — use domain Rank() method (Q-1 FIX: removed duplicate severityRank). if w.config.MinSeverity != "" { if incident.Severity.Rank() < w.config.MinSeverity.Rank() { return nil } } data, err := json.Marshal(incident) if err != nil { return nil } payload := WebhookPayload{ EventType: eventType, Timestamp: time.Now().UTC(), Source: "sentinel-soc", Data: data, } body, err := json.Marshal(payload) if err != nil { return nil } // Fire all endpoints in parallel var wg sync.WaitGroup results := make([]WebhookResult, len(w.config.Endpoints)) for i, endpoint := range w.config.Endpoints { wg.Add(1) go func(idx int, url string) { defer wg.Done() results[idx] = w.sendWithRetry(url, body) }(i, endpoint) } wg.Wait() // Update stats w.mu.Lock() for _, r := range results { if r.Success { w.sent++ } else { w.failed++ } } w.mu.Unlock() return results } // NotifySensorOffline sends a sensor offline alert to all endpoints. func (w *WebhookNotifier) NotifySensorOffline(sensor domsoc.Sensor) []WebhookResult { if !w.enabled { return nil } data, _ := json.Marshal(sensor) payload := WebhookPayload{ EventType: "sensor_offline", Timestamp: time.Now().UTC(), Source: "sentinel-soc", Data: data, } body, _ := json.Marshal(payload) var wg sync.WaitGroup results := make([]WebhookResult, len(w.config.Endpoints)) for i, endpoint := range w.config.Endpoints { wg.Add(1) go func(idx int, url string) { defer wg.Done() results[idx] = w.sendWithRetry(url, body) }(i, endpoint) } wg.Wait() return results } // sendWithRetry sends POST request with exponential backoff. func (w *WebhookNotifier) sendWithRetry(url string, body []byte) WebhookResult { result := WebhookResult{Endpoint: url} for attempt := 0; attempt <= w.config.MaxRetries; attempt++ { result.Retries = attempt req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { result.Error = err.Error() return result } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "SENTINEL-SOC/1.0") req.Header.Set("X-Sentinel-Event", "soc-webhook") // Add custom headers for k, v := range w.config.Headers { req.Header.Set(k, v) } resp, err := w.client.Do(req) if err != nil { result.Error = err.Error() if attempt < w.config.MaxRetries { backoff := time.Duration(1<= 200 && resp.StatusCode < 300 { result.Success = true return result } result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) if attempt < w.config.MaxRetries { backoff := time.Duration(1<