mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-27 21:36:21 +02:00
449 lines
13 KiB
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
|
|
}
|
|
|