Skip to content
Merged
17 changes: 10 additions & 7 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,16 @@ func initModules(db *gorm.DB, cfg *config) *modules {
notificationsMod := notifications.NewModule(db, auditMod.Logger(), joblease.New(db),
env.Int("NOTIFICATIONS_READ_RETENTION_DAYS", 30, false),
env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false))
soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), tenantLister)
// Incidents module is built early so its usecase can back the SOAR incident
// executor. Its own deps (db + mail + config + alerts + audit) are already
// available at this point.
incidentsMod := incidents.NewModule(
db,
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()),
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
auditMod.Logger(),
)
soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), incidentsMod.GetIncidentUsecase(), mailMod.Service(), tenantLister)
eventProcessingMod := eventprocessing.NewModule(db, events, auditMod.Logger(), cfg.playgroundBaseURL, cfg.internalKey)

alertsMod.SetCorrelationResolver(eventProcessingMod)
Expand Down Expand Up @@ -266,12 +275,6 @@ func initModules(db *gorm.DB, cfg *config) *modules {
socAIMod := socai.NewModule(cfg.socAIBaseURL, cfg.internalKey, cipher,
env.String("INTEGRATIONS_CONFIG_DIR", "/workdir/pipeline", false),
env.String("UPDATES_DIR", "/updates", false), aiQuota, joblease.New(db))
incidentsMod := incidents.NewModule(
db,
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()),
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
auditMod.Logger(),
)
adauditMod := adaudit.NewModule(db)
storageMod := storage.NewModule(events, env.String("CLICKHOUSE_CONFIG_DIR", "/clickhouse-conf", false))
threatintelMod := threatintel.NewModule(
Expand Down
123 changes: 123 additions & 0 deletions backend/modules/soar/executor/conditional.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package executor

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/tidwall/gjson"

"github.com/utmstack/utmstack/backend/modules/soar/domain"
)

// Conditional evaluates a list of predicates against the execution's merged
// context bag and returns success only when all of them are true (AND). A
// failing predicate returns an error so the dispatcher routes the flow down
// the node's onError branch — no bespoke edge kind needed.
// ponytail: reuses domain.FilterType and gjson (already vendored via variable
// + execution interpolation); OnSuccess/OnError already model the true/false
// exits of a conditional.
type Conditional struct{}

func NewConditional() *Conditional { return &Conditional{} }

func (Conditional) Type() string { return "conditional" }

type conditionalParams struct {
Conditions []domain.FilterType `json:"conditions"`
}

func (c *Conditional) Execute(_ context.Context, exec *domain.SoarExecution) (json.RawMessage, error) {
var p conditionalParams
if len(exec.Params) > 0 {
if err := json.Unmarshal(exec.Params, &p); err != nil {
return nil, fmt.Errorf("soar conditional: params: %w", err)
}
}
if len(p.Conditions) == 0 {
return nil, errors.New("soar conditional: at least one condition is required")
}
src := string(exec.Context)
if src == "" {
src = "{}"
}
for _, cond := range p.Conditions {
if !evaluateFilter(src, cond) {
exec.Result = fmt.Sprintf("condition failed: %s %s %v", cond.Field, cond.Operator, cond.Value)
return nil, errors.New(exec.Result)
}
}
exec.Result = "all conditions matched"
return nil, nil
}

func evaluateFilter(src string, cond domain.FilterType) bool {
val := gjson.Get(src, cond.Field)
switch cond.Operator {
case domain.OperatorExists:
return val.Exists()
case domain.OperatorNotExists:
return !val.Exists()
}
got := val.String()
switch cond.Operator {
case domain.OperatorIS:
return got == asString(cond.Value)
case domain.OperatorISNot:
return got != asString(cond.Value)
case domain.OperatorContains:
return strings.Contains(got, asString(cond.Value))
case domain.OperatorNotContains:
return !strings.Contains(got, asString(cond.Value))
case domain.OperatorStartWith:
return strings.HasPrefix(got, asString(cond.Value))
case domain.OperatorNotStartWith:
return !strings.HasPrefix(got, asString(cond.Value))
case domain.OperatorEndsWith:
return strings.HasSuffix(got, asString(cond.Value))
case domain.OperatorNotEndsWith:
return !strings.HasSuffix(got, asString(cond.Value))
case domain.OperatorIsOneOf:
return oneOf(asStringSlice(cond.Value), got)
case domain.OperatorIsNotOneOf:
return !oneOf(asStringSlice(cond.Value), got)
}
return false
}

func asString(v any) string {
switch t := v.(type) {
case string:
return t
case nil:
return ""
default:
b, _ := json.Marshal(t)
return string(b)
}
}

func asStringSlice(v any) []string {
switch t := v.(type) {
case []string:
return t
case []any:
out := make([]string, 0, len(t))
for _, x := range t {
out = append(out, asString(x))
}
return out
}
return nil
}

func oneOf(hay []string, needle string) bool {
for _, h := range hay {
if h == needle {
return true
}
}
return false
}
44 changes: 44 additions & 0 deletions backend/modules/soar/executor/conditional_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package executor

import (
"context"
"encoding/json"
"testing"

"github.com/utmstack/utmstack/backend/modules/soar/domain"
)

func TestConditional_AllMatchTakesSuccessBranch(t *testing.T) {
c := NewConditional()
exec := &domain.SoarExecution{
Kind: domain.NodeKindExecutor,
Context: json.RawMessage(`{"alert":{"severity":"high","tags":["prod","edr"]}}`),
Params: json.RawMessage(`{"conditions":[
{"field":"alert.severity","operator":"IS","value":"high"},
{"field":"alert.tags","operator":"CONTAINS","value":"edr"}
]}`),
}
if _, err := c.Execute(context.Background(), exec); err != nil {
t.Fatalf("expected success, got %v", err)
}
}

func TestConditional_MismatchRoutesToOnError(t *testing.T) {
c := NewConditional()
exec := &domain.SoarExecution{
Kind: domain.NodeKindExecutor,
Context: json.RawMessage(`{"alert":{"severity":"low"}}`),
Params: json.RawMessage(`{"conditions":[{"field":"alert.severity","operator":"IS","value":"high"}]}`),
}
if _, err := c.Execute(context.Background(), exec); err == nil {
t.Fatal("expected error so the dispatcher takes the onError branch")
}
}

func TestConditional_MissingParamsFails(t *testing.T) {
c := NewConditional()
exec := &domain.SoarExecution{Context: json.RawMessage(`{}`)}
if _, err := c.Execute(context.Background(), exec); err == nil {
t.Fatal("expected error when no conditions are configured")
}
}
88 changes: 88 additions & 0 deletions backend/modules/soar/executor/incident.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package executor

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/tidwall/gjson"

incidentsdomain "github.com/utmstack/utmstack/backend/modules/incidents/domain"
incidentsdto "github.com/utmstack/utmstack/backend/modules/incidents/dto"
soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain"
)

// IncidentOpener is the narrow slice of the incidents usecase that the SOAR
// incident executor consumes. Keeps this package free of the broader incidents
// import and lets tests swap in a fake.
type IncidentOpener interface {
Create(ctx context.Context, userEmail string, req incidentsdto.CreateIncidentRequest) (*incidentsdomain.Incident, error)
}

// Incident opens an incident and links the alert that fired the flow. Params
// are just name + description — alert identity (id/name/severity) comes from
// the exec's built-in AlertID and the context bag populated by the dispatcher.
// ponytail: reuses incidents.CreateIncidentRequest verbatim — no shadow DTO.
type Incident struct{ client IncidentOpener }

func NewIncident(c IncidentOpener) *Incident { return &Incident{client: c} }

func (Incident) Type() string { return "incident" }

type incidentParams struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}

func (i *Incident) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) {
if i.client == nil {
return nil, errors.New("soar incident: client not configured")
}
var p incidentParams
if len(exec.Params) > 0 {
if err := json.Unmarshal(exec.Params, &p); err != nil {
return nil, fmt.Errorf("soar incident: params: %w", err)
}
}
name := strings.TrimSpace(p.Name)
if name == "" {
return nil, errors.New("soar incident: name is required")
}
if strings.TrimSpace(exec.AlertID) == "" {
return nil, errors.New("soar incident: no alert linked to this execution")
}

src := string(exec.Context)
if src == "" {
src = "{}"
}
alertName := gjson.Get(src, "alert.name").String()
if alertName == "" {
alertName = exec.AlertID
}
severity := gjson.Get(src, "alert.severity").String()
if severity == "" {
severity = "Low"
}

req := incidentsdto.CreateIncidentRequest{
IncidentName: name,
AlertList: []incidentsdto.AlertLinkItem{{
AlertID: exec.AlertID,
AlertName: alertName,
AlertSeverity: severity,
}},
}
if desc := strings.TrimSpace(p.Description); desc != "" {
req.IncidentDescription = &desc
}

inc, err := i.client.Create(ctx, "", req)
if err != nil {
return nil, fmt.Errorf("soar incident: create: %w", err)
}
exec.Result = fmt.Sprintf("opened incident %s: %s", inc.ID.String(), inc.Name)
return nil, nil
}
72 changes: 72 additions & 0 deletions backend/modules/soar/executor/mail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package executor

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"

maildomain "github.com/utmstack/utmstack/backend/internal/mail/domain"
soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain"
)

// MailSender is the narrow slice of the mail service that the SOAR mail
// executor consumes. Keeps this package free of the broader mail import and
// lets tests swap in a fake.
type MailSender interface {
SendMail(ctx context.Context, to []string, cc []string, subject, body string, attachments []maildomain.Attatchment) error
}

// Mail sends an email via the tenant's configured SMTP settings. Params are
// to, cc, subject, body — all $()-templates are already interpolated by the
// dispatcher before Execute runs.
// ponytail: no attachments (YAGNI); comma-split addresses, no header parser.
type Mail struct{ client MailSender }

func NewMail(c MailSender) *Mail { return &Mail{client: c} }

func (Mail) Type() string { return "mail" }

type mailParams struct {
To string `json:"to"`
CC string `json:"cc,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
}

func (m *Mail) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) {
if m.client == nil {
return nil, errors.New("soar mail: client not configured")
}
var p mailParams
if len(exec.Params) > 0 {
if err := json.Unmarshal(exec.Params, &p); err != nil {
return nil, fmt.Errorf("soar mail: params: %w", err)
}
}
to := splitAddresses(p.To)
if len(to) == 0 {
return nil, errors.New("soar mail: at least one recipient is required")
}
if strings.TrimSpace(p.Subject) == "" {
return nil, errors.New("soar mail: subject is required")
}
cc := splitAddresses(p.CC)
if err := m.client.SendMail(ctx, to, cc, p.Subject, p.Body, nil); err != nil {
return nil, fmt.Errorf("soar mail: send: %w", err)
}
exec.Result = fmt.Sprintf("sent email to %d recipient(s): %s", len(to), p.Subject)
return nil, nil
}

func splitAddresses(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if addr := strings.TrimSpace(part); addr != "" {
out = append(out, addr)
}
}
return out
}
Loading
Loading