Skip to content

Commit d42ce27

Browse files
Backlog/v12 soar flows refactor (#2516)
* feat(soar): add conditional if/else node * i18n[frontend](soar): translate conditional node hint * refactor(soar): drop select enrichment executor * fix[frontend](soar-flows): improved node inspector * feat[frontend](soar): specialized http node editor * feat(soar): add incident node that opens an incident from the alert * feat(soar): add send-email node * feat[frontend](soar): insert-field menu on http + incident inspectors * refactor[frontend](soar): make canvas the primary surface
1 parent 1610206 commit d42ce27

30 files changed

Lines changed: 1687 additions & 304 deletions

backend/modules.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,16 @@ func initModules(db *gorm.DB, cfg *config) *modules {
207207
notificationsMod := notifications.NewModule(db, auditMod.Logger(), joblease.New(db),
208208
env.Int("NOTIFICATIONS_READ_RETENTION_DAYS", 30, false),
209209
env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false))
210-
soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), tenantLister)
210+
// Incidents module is built early so its usecase can back the SOAR incident
211+
// executor. Its own deps (db + mail + config + alerts + audit) are already
212+
// available at this point.
213+
incidentsMod := incidents.NewModule(
214+
db,
215+
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()),
216+
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
217+
auditMod.Logger(),
218+
)
219+
soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), incidentsMod.GetIncidentUsecase(), mailMod.Service(), tenantLister)
211220
eventProcessingMod := eventprocessing.NewModule(db, events, auditMod.Logger(), cfg.playgroundBaseURL, cfg.internalKey)
212221

213222
alertsMod.SetCorrelationResolver(eventProcessingMod)
@@ -266,12 +275,6 @@ func initModules(db *gorm.DB, cfg *config) *modules {
266275
socAIMod := socai.NewModule(cfg.socAIBaseURL, cfg.internalKey, cipher,
267276
env.String("INTEGRATIONS_CONFIG_DIR", "/workdir/pipeline", false),
268277
env.String("UPDATES_DIR", "/updates", false), aiQuota, joblease.New(db))
269-
incidentsMod := incidents.NewModule(
270-
db,
271-
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()),
272-
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
273-
auditMod.Logger(),
274-
)
275278
adauditMod := adaudit.NewModule(db)
276279
storageMod := storage.NewModule(events, env.String("CLICKHOUSE_CONFIG_DIR", "/clickhouse-conf", false))
277280
threatintelMod := threatintel.NewModule(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/tidwall/gjson"
11+
12+
"github.com/utmstack/utmstack/backend/modules/soar/domain"
13+
)
14+
15+
// Conditional evaluates a list of predicates against the execution's merged
16+
// context bag and returns success only when all of them are true (AND). A
17+
// failing predicate returns an error so the dispatcher routes the flow down
18+
// the node's onError branch — no bespoke edge kind needed.
19+
// ponytail: reuses domain.FilterType and gjson (already vendored via variable
20+
// + execution interpolation); OnSuccess/OnError already model the true/false
21+
// exits of a conditional.
22+
type Conditional struct{}
23+
24+
func NewConditional() *Conditional { return &Conditional{} }
25+
26+
func (Conditional) Type() string { return "conditional" }
27+
28+
type conditionalParams struct {
29+
Conditions []domain.FilterType `json:"conditions"`
30+
}
31+
32+
func (c *Conditional) Execute(_ context.Context, exec *domain.SoarExecution) (json.RawMessage, error) {
33+
var p conditionalParams
34+
if len(exec.Params) > 0 {
35+
if err := json.Unmarshal(exec.Params, &p); err != nil {
36+
return nil, fmt.Errorf("soar conditional: params: %w", err)
37+
}
38+
}
39+
if len(p.Conditions) == 0 {
40+
return nil, errors.New("soar conditional: at least one condition is required")
41+
}
42+
src := string(exec.Context)
43+
if src == "" {
44+
src = "{}"
45+
}
46+
for _, cond := range p.Conditions {
47+
if !evaluateFilter(src, cond) {
48+
exec.Result = fmt.Sprintf("condition failed: %s %s %v", cond.Field, cond.Operator, cond.Value)
49+
return nil, errors.New(exec.Result)
50+
}
51+
}
52+
exec.Result = "all conditions matched"
53+
return nil, nil
54+
}
55+
56+
func evaluateFilter(src string, cond domain.FilterType) bool {
57+
val := gjson.Get(src, cond.Field)
58+
switch cond.Operator {
59+
case domain.OperatorExists:
60+
return val.Exists()
61+
case domain.OperatorNotExists:
62+
return !val.Exists()
63+
}
64+
got := val.String()
65+
switch cond.Operator {
66+
case domain.OperatorIS:
67+
return got == asString(cond.Value)
68+
case domain.OperatorISNot:
69+
return got != asString(cond.Value)
70+
case domain.OperatorContains:
71+
return strings.Contains(got, asString(cond.Value))
72+
case domain.OperatorNotContains:
73+
return !strings.Contains(got, asString(cond.Value))
74+
case domain.OperatorStartWith:
75+
return strings.HasPrefix(got, asString(cond.Value))
76+
case domain.OperatorNotStartWith:
77+
return !strings.HasPrefix(got, asString(cond.Value))
78+
case domain.OperatorEndsWith:
79+
return strings.HasSuffix(got, asString(cond.Value))
80+
case domain.OperatorNotEndsWith:
81+
return !strings.HasSuffix(got, asString(cond.Value))
82+
case domain.OperatorIsOneOf:
83+
return oneOf(asStringSlice(cond.Value), got)
84+
case domain.OperatorIsNotOneOf:
85+
return !oneOf(asStringSlice(cond.Value), got)
86+
}
87+
return false
88+
}
89+
90+
func asString(v any) string {
91+
switch t := v.(type) {
92+
case string:
93+
return t
94+
case nil:
95+
return ""
96+
default:
97+
b, _ := json.Marshal(t)
98+
return string(b)
99+
}
100+
}
101+
102+
func asStringSlice(v any) []string {
103+
switch t := v.(type) {
104+
case []string:
105+
return t
106+
case []any:
107+
out := make([]string, 0, len(t))
108+
for _, x := range t {
109+
out = append(out, asString(x))
110+
}
111+
return out
112+
}
113+
return nil
114+
}
115+
116+
func oneOf(hay []string, needle string) bool {
117+
for _, h := range hay {
118+
if h == needle {
119+
return true
120+
}
121+
}
122+
return false
123+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/utmstack/utmstack/backend/modules/soar/domain"
9+
)
10+
11+
func TestConditional_AllMatchTakesSuccessBranch(t *testing.T) {
12+
c := NewConditional()
13+
exec := &domain.SoarExecution{
14+
Kind: domain.NodeKindExecutor,
15+
Context: json.RawMessage(`{"alert":{"severity":"high","tags":["prod","edr"]}}`),
16+
Params: json.RawMessage(`{"conditions":[
17+
{"field":"alert.severity","operator":"IS","value":"high"},
18+
{"field":"alert.tags","operator":"CONTAINS","value":"edr"}
19+
]}`),
20+
}
21+
if _, err := c.Execute(context.Background(), exec); err != nil {
22+
t.Fatalf("expected success, got %v", err)
23+
}
24+
}
25+
26+
func TestConditional_MismatchRoutesToOnError(t *testing.T) {
27+
c := NewConditional()
28+
exec := &domain.SoarExecution{
29+
Kind: domain.NodeKindExecutor,
30+
Context: json.RawMessage(`{"alert":{"severity":"low"}}`),
31+
Params: json.RawMessage(`{"conditions":[{"field":"alert.severity","operator":"IS","value":"high"}]}`),
32+
}
33+
if _, err := c.Execute(context.Background(), exec); err == nil {
34+
t.Fatal("expected error so the dispatcher takes the onError branch")
35+
}
36+
}
37+
38+
func TestConditional_MissingParamsFails(t *testing.T) {
39+
c := NewConditional()
40+
exec := &domain.SoarExecution{Context: json.RawMessage(`{}`)}
41+
if _, err := c.Execute(context.Background(), exec); err == nil {
42+
t.Fatal("expected error when no conditions are configured")
43+
}
44+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/tidwall/gjson"
11+
12+
incidentsdomain "github.com/utmstack/utmstack/backend/modules/incidents/domain"
13+
incidentsdto "github.com/utmstack/utmstack/backend/modules/incidents/dto"
14+
soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain"
15+
)
16+
17+
// IncidentOpener is the narrow slice of the incidents usecase that the SOAR
18+
// incident executor consumes. Keeps this package free of the broader incidents
19+
// import and lets tests swap in a fake.
20+
type IncidentOpener interface {
21+
Create(ctx context.Context, userEmail string, req incidentsdto.CreateIncidentRequest) (*incidentsdomain.Incident, error)
22+
}
23+
24+
// Incident opens an incident and links the alert that fired the flow. Params
25+
// are just name + description — alert identity (id/name/severity) comes from
26+
// the exec's built-in AlertID and the context bag populated by the dispatcher.
27+
// ponytail: reuses incidents.CreateIncidentRequest verbatim — no shadow DTO.
28+
type Incident struct{ client IncidentOpener }
29+
30+
func NewIncident(c IncidentOpener) *Incident { return &Incident{client: c} }
31+
32+
func (Incident) Type() string { return "incident" }
33+
34+
type incidentParams struct {
35+
Name string `json:"name"`
36+
Description string `json:"description,omitempty"`
37+
}
38+
39+
func (i *Incident) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) {
40+
if i.client == nil {
41+
return nil, errors.New("soar incident: client not configured")
42+
}
43+
var p incidentParams
44+
if len(exec.Params) > 0 {
45+
if err := json.Unmarshal(exec.Params, &p); err != nil {
46+
return nil, fmt.Errorf("soar incident: params: %w", err)
47+
}
48+
}
49+
name := strings.TrimSpace(p.Name)
50+
if name == "" {
51+
return nil, errors.New("soar incident: name is required")
52+
}
53+
if strings.TrimSpace(exec.AlertID) == "" {
54+
return nil, errors.New("soar incident: no alert linked to this execution")
55+
}
56+
57+
src := string(exec.Context)
58+
if src == "" {
59+
src = "{}"
60+
}
61+
alertName := gjson.Get(src, "alert.name").String()
62+
if alertName == "" {
63+
alertName = exec.AlertID
64+
}
65+
severity := gjson.Get(src, "alert.severity").String()
66+
if severity == "" {
67+
severity = "Low"
68+
}
69+
70+
req := incidentsdto.CreateIncidentRequest{
71+
IncidentName: name,
72+
AlertList: []incidentsdto.AlertLinkItem{{
73+
AlertID: exec.AlertID,
74+
AlertName: alertName,
75+
AlertSeverity: severity,
76+
}},
77+
}
78+
if desc := strings.TrimSpace(p.Description); desc != "" {
79+
req.IncidentDescription = &desc
80+
}
81+
82+
inc, err := i.client.Create(ctx, "", req)
83+
if err != nil {
84+
return nil, fmt.Errorf("soar incident: create: %w", err)
85+
}
86+
exec.Result = fmt.Sprintf("opened incident %s: %s", inc.ID.String(), inc.Name)
87+
return nil, nil
88+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
maildomain "github.com/utmstack/utmstack/backend/internal/mail/domain"
11+
soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain"
12+
)
13+
14+
// MailSender is the narrow slice of the mail service that the SOAR mail
15+
// executor consumes. Keeps this package free of the broader mail import and
16+
// lets tests swap in a fake.
17+
type MailSender interface {
18+
SendMail(ctx context.Context, to []string, cc []string, subject, body string, attachments []maildomain.Attatchment) error
19+
}
20+
21+
// Mail sends an email via the tenant's configured SMTP settings. Params are
22+
// to, cc, subject, body — all $()-templates are already interpolated by the
23+
// dispatcher before Execute runs.
24+
// ponytail: no attachments (YAGNI); comma-split addresses, no header parser.
25+
type Mail struct{ client MailSender }
26+
27+
func NewMail(c MailSender) *Mail { return &Mail{client: c} }
28+
29+
func (Mail) Type() string { return "mail" }
30+
31+
type mailParams struct {
32+
To string `json:"to"`
33+
CC string `json:"cc,omitempty"`
34+
Subject string `json:"subject"`
35+
Body string `json:"body"`
36+
}
37+
38+
func (m *Mail) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) {
39+
if m.client == nil {
40+
return nil, errors.New("soar mail: client not configured")
41+
}
42+
var p mailParams
43+
if len(exec.Params) > 0 {
44+
if err := json.Unmarshal(exec.Params, &p); err != nil {
45+
return nil, fmt.Errorf("soar mail: params: %w", err)
46+
}
47+
}
48+
to := splitAddresses(p.To)
49+
if len(to) == 0 {
50+
return nil, errors.New("soar mail: at least one recipient is required")
51+
}
52+
if strings.TrimSpace(p.Subject) == "" {
53+
return nil, errors.New("soar mail: subject is required")
54+
}
55+
cc := splitAddresses(p.CC)
56+
if err := m.client.SendMail(ctx, to, cc, p.Subject, p.Body, nil); err != nil {
57+
return nil, fmt.Errorf("soar mail: send: %w", err)
58+
}
59+
exec.Result = fmt.Sprintf("sent email to %d recipient(s): %s", len(to), p.Subject)
60+
return nil, nil
61+
}
62+
63+
func splitAddresses(s string) []string {
64+
parts := strings.Split(s, ",")
65+
out := make([]string, 0, len(parts))
66+
for _, part := range parts {
67+
if addr := strings.TrimSpace(part); addr != "" {
68+
out = append(out, addr)
69+
}
70+
}
71+
return out
72+
}

0 commit comments

Comments
 (0)