gomcp/internal/domain/soc/executors.go

449 lines
13 KiB
Go

package soc
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
// ActionExecutor defines the interface for playbook action handlers.
// Each executor implements a specific action type (webhook, block_ip, log, etc.)
type ActionExecutor interface {
// Type returns the action type this executor handles (e.g., "webhook", "block_ip", "log").
Type() string
// Execute runs the action with the given parameters.
// Returns a result summary or error.
Execute(params ActionParams) (string, error)
}
// ActionParams contains the context passed to an action executor.
type ActionParams struct {
IncidentID string `json:"incident_id"`
Severity EventSeverity `json:"severity"`
Category string `json:"category"`
Description string `json:"description"`
EventCount int `json:"event_count"`
RuleName string `json:"rule_name"`
Extra map[string]any `json:"extra,omitempty"`
}
// ExecutorRegistry manages registered action executors.
type ExecutorRegistry struct {
mu sync.RWMutex
executors map[string]ActionExecutor
}
// NewExecutorRegistry creates a registry with the default LogExecutor.
func NewExecutorRegistry() *ExecutorRegistry {
reg := &ExecutorRegistry{
executors: make(map[string]ActionExecutor),
}
reg.Register(&LogExecutor{})
return reg
}
// Register adds an executor to the registry.
func (r *ExecutorRegistry) Register(exec ActionExecutor) {
r.mu.Lock()
defer r.mu.Unlock()
r.executors[exec.Type()] = exec
}
// Execute runs the named action. Returns error if executor not found.
func (r *ExecutorRegistry) Execute(actionType string, params ActionParams) (string, error) {
r.mu.RLock()
exec, ok := r.executors[actionType]
r.mu.RUnlock()
if !ok {
return "", fmt.Errorf("executor not found: %s", actionType)
}
return exec.Execute(params)
}
// List returns all registered executor types.
func (r *ExecutorRegistry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
types := make([]string, 0, len(r.executors))
for t := range r.executors {
types = append(types, t)
}
return types
}
// --- Built-in Executors ---
// LogExecutor logs the action (default, always available).
type LogExecutor struct{}
func (e *LogExecutor) Type() string { return "log" }
func (e *LogExecutor) Execute(params ActionParams) (string, error) {
slog.Info("playbook action executed",
"type", "log",
"incident_id", params.IncidentID,
"severity", params.Severity,
"category", params.Category,
"rule", params.RuleName,
)
return "logged", nil
}
// WebhookExecutor sends HTTP POST to a webhook URL (Slack, PagerDuty, etc.)
type WebhookExecutor struct {
URL string
Headers map[string]string
client *http.Client
}
// NewWebhookExecutor creates a webhook executor for the given URL.
func NewWebhookExecutor(url string, headers map[string]string) *WebhookExecutor {
return &WebhookExecutor{
URL: url,
Headers: headers,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (e *WebhookExecutor) Type() string { return "webhook" }
func (e *WebhookExecutor) Execute(params ActionParams) (string, error) {
payload, err := json.Marshal(map[string]any{
"incident_id": params.IncidentID,
"severity": params.Severity,
"category": params.Category,
"description": params.Description,
"event_count": params.EventCount,
"rule_name": params.RuleName,
"timestamp": time.Now().Format(time.RFC3339),
"source": "sentinel-soc",
})
if err != nil {
return "", fmt.Errorf("webhook: marshal: %w", err)
}
req, err := http.NewRequest(http.MethodPost, e.URL, bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("webhook: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range e.Headers {
req.Header.Set(k, v)
}
resp, err := e.client.Do(req)
if err != nil {
slog.Error("webhook delivery failed", "url", e.URL, "error", err)
return "", fmt.Errorf("webhook: send: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slog.Warn("webhook returned error", "url", e.URL, "status", resp.StatusCode)
return "", fmt.Errorf("webhook: HTTP %d", resp.StatusCode)
}
slog.Info("webhook delivered", "url", e.URL, "status", resp.StatusCode,
"incident_id", params.IncidentID)
return fmt.Sprintf("webhook: HTTP %d", resp.StatusCode), nil
}
// BlockIPExecutor stubs a firewall block action.
// In production, this would call a firewall API (iptables, AWS SG, etc.)
type BlockIPExecutor struct{}
func (e *BlockIPExecutor) Type() string { return "block_ip" }
func (e *BlockIPExecutor) Execute(params ActionParams) (string, error) {
ip, _ := params.Extra["ip"].(string)
if ip == "" {
return "", fmt.Errorf("block_ip: missing ip in extra params")
}
// TODO: Implement actual firewall API call
slog.Warn("block_ip action (stub)",
"ip", ip,
"incident_id", params.IncidentID,
)
return fmt.Sprintf("block_ip: %s (stub — implement firewall API)", ip), nil
}
// NotifyExecutor sends a formatted alert notification via HTTP POST.
// Supports Slack, Telegram, PagerDuty, or any webhook-compatible endpoint.
type NotifyExecutor struct {
DefaultURL string
Headers map[string]string
client *http.Client
}
// NewNotifyExecutor creates a notification executor with a default webhook URL.
func NewNotifyExecutor(url string) *NotifyExecutor {
return &NotifyExecutor{
DefaultURL: url,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (e *NotifyExecutor) Type() string { return "notify" }
func (e *NotifyExecutor) Execute(params ActionParams) (string, error) {
channel, _ := params.Extra["channel"].(string)
if channel == "" {
channel = "soc-alerts"
}
url := e.DefaultURL
if customURL, ok := params.Extra["webhook_url"].(string); ok && customURL != "" {
url = customURL
}
// Build structured alert payload (Slack-compatible format)
sevEmoji := map[EventSeverity]string{
SeverityCritical: "🔴", SeverityHigh: "🟠",
SeverityMedium: "🟡", SeverityLow: "🔵", SeverityInfo: "⚪",
}
emoji := sevEmoji[params.Severity]
if emoji == "" {
emoji = "⚠️"
}
payload := map[string]any{
"text": fmt.Sprintf("%s *[%s] %s*\nIncident: `%s` | Events: %d\n%s",
emoji, params.Severity, params.Category,
params.IncidentID, params.EventCount, params.Description),
"channel": channel,
"username": "SYNTREX SOC",
// Slack blocks for rich formatting
"blocks": []map[string]any{
{
"type": "section",
"text": map[string]string{
"type": "mrkdwn",
"text": fmt.Sprintf("%s *%s Alert — %s*", emoji, params.Severity, params.Category),
},
},
{
"type": "section",
"fields": []map[string]string{
{"type": "mrkdwn", "text": fmt.Sprintf("*Incident:*\n`%s`", params.IncidentID)},
{"type": "mrkdwn", "text": fmt.Sprintf("*Events:*\n%d", params.EventCount)},
{"type": "mrkdwn", "text": fmt.Sprintf("*Rule:*\n%s", params.RuleName)},
{"type": "mrkdwn", "text": fmt.Sprintf("*Severity:*\n%s", params.Severity)},
},
},
},
}
if url == "" {
// No webhook configured — log and succeed (graceful degradation)
slog.Info("notify: no webhook URL configured, logging alert",
"channel", channel, "incident_id", params.IncidentID, "severity", params.Severity)
return fmt.Sprintf("notify: logged to channel=%s (no webhook URL)", channel), nil
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("notify: marshal: %w", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("notify: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range e.Headers {
req.Header.Set(k, v)
}
resp, err := e.client.Do(req)
if err != nil {
slog.Error("notify: delivery failed", "url", url, "error", err)
return "", fmt.Errorf("notify: send: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("notify: HTTP %d from %s", resp.StatusCode, url)
}
slog.Info("notify: alert delivered",
"channel", channel, "url", url, "status", resp.StatusCode,
"incident_id", params.IncidentID)
return fmt.Sprintf("notify: delivered to %s (HTTP %d)", channel, resp.StatusCode), nil
}
// QuarantineExecutor marks a session or IP as quarantined.
// Maintains an in-memory blocklist and logs quarantine actions.
type QuarantineExecutor struct {
mu sync.RWMutex
blocklist map[string]time.Time // IP/session → quarantine expiry
}
func NewQuarantineExecutor() *QuarantineExecutor {
return &QuarantineExecutor{
blocklist: make(map[string]time.Time),
}
}
func (e *QuarantineExecutor) Type() string { return "quarantine" }
func (e *QuarantineExecutor) Execute(params ActionParams) (string, error) {
scope, _ := params.Extra["scope"].(string)
if scope == "" {
scope = "session"
}
target, _ := params.Extra["target"].(string)
if target == "" {
target, _ = params.Extra["ip"].(string)
}
if target == "" {
target = params.IncidentID // Quarantine by incident
}
duration := 1 * time.Hour
if durStr, ok := params.Extra["duration"].(string); ok {
if d, err := time.ParseDuration(durStr); err == nil {
duration = d
}
}
e.mu.Lock()
e.blocklist[target] = time.Now().Add(duration)
e.mu.Unlock()
slog.Warn("quarantine: target isolated",
"scope", scope,
"target", target,
"duration", duration,
"incident_id", params.IncidentID,
"severity", params.Severity,
)
return fmt.Sprintf("quarantine: %s=%s isolated for %s", scope, target, duration), nil
}
// IsQuarantined checks if a target is currently quarantined.
func (e *QuarantineExecutor) IsQuarantined(target string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
expiry, ok := e.blocklist[target]
if !ok {
return false
}
if time.Now().After(expiry) {
return false
}
return true
}
// QuarantinedTargets returns all currently active quarantines.
func (e *QuarantineExecutor) QuarantinedTargets() map[string]time.Time {
e.mu.RLock()
defer e.mu.RUnlock()
now := time.Now()
active := make(map[string]time.Time)
for target, expiry := range e.blocklist {
if now.Before(expiry) {
active[target] = expiry
}
}
return active
}
// EscalateExecutor auto-assigns incidents and fires escalation webhooks.
type EscalateExecutor struct {
EscalationURL string // Webhook URL for escalation alerts (PagerDuty, etc.)
client *http.Client
}
func NewEscalateExecutor(url string) *EscalateExecutor {
return &EscalateExecutor{
EscalationURL: url,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (e *EscalateExecutor) Type() string { return "escalate" }
func (e *EscalateExecutor) Execute(params ActionParams) (string, error) {
team, _ := params.Extra["team"].(string)
if team == "" {
team = "soc-team"
}
slog.Warn("escalate: incident escalated",
"team", team,
"incident_id", params.IncidentID,
"severity", params.Severity,
"category", params.Category,
)
// Fire escalation webhook if configured
if e.EscalationURL != "" {
payload, _ := json.Marshal(map[string]any{
"event_type": "escalation",
"incident_id": params.IncidentID,
"severity": params.Severity,
"category": params.Category,
"team": team,
"description": params.Description,
"timestamp": time.Now().Format(time.RFC3339),
"source": "syntrex-soc",
})
req, err := http.NewRequest(http.MethodPost, e.EscalationURL, bytes.NewReader(payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
if resp, err := e.client.Do(req); err == nil {
resp.Body.Close()
slog.Info("escalate: webhook delivered", "url", e.EscalationURL, "status", resp.StatusCode)
} else {
slog.Error("escalate: webhook failed", "url", e.EscalationURL, "error", err)
}
}
}
return fmt.Sprintf("escalate: assigned to team=%s", team), nil
}
// --- ExecutorActionHandler bridges PlaybookEngine → ExecutorRegistry ---
// ExecutorActionHandler implements ActionHandler by delegating to ExecutorRegistry.
// This is the bridge that makes playbook actions actually execute real handlers.
type ExecutorActionHandler struct {
Registry *ExecutorRegistry
}
func (h *ExecutorActionHandler) Handle(action PlaybookAction, incidentID string) error {
params := ActionParams{
IncidentID: incidentID,
Extra: make(map[string]any),
}
// Copy playbook action params to executor params
for k, v := range action.Params {
params.Extra[k] = v
}
result, err := h.Registry.Execute(action.Type, params)
if err != nil {
slog.Error("playbook action failed",
"type", action.Type,
"incident_id", incidentID,
"error", err,
)
return err
}
slog.Info("playbook action executed",
"type", action.Type,
"incident_id", incidentID,
"result", result,
)
return nil
}