Skip to content

Commit ab3ef05

Browse files
Merge branch 'release/v12.0.0' into backlog/v12_home_chat
2 parents d1f68a4 + fb6ec4c commit ab3ef05

60 files changed

Lines changed: 783 additions & 148 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/modules/mcp/tools_soar.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type soarRuleCreateInput struct {
2727
Name string `json:"name"`
2828
Description string `json:"description,omitempty"`
2929
Conditions []dto.FilterVM `json:"conditions"`
30-
Commands []string `json:"commands"`
30+
Commands []dto.FlowCommandVM `json:"commands"`
3131
Active bool `json:"active"`
3232
AgentPlatform string `json:"agent_platform"`
3333
DefaultAgent string `json:"default_agent,omitempty"`
@@ -41,7 +41,7 @@ type soarRuleUpdateInput struct {
4141
Name string `json:"name"`
4242
Description string `json:"description,omitempty"`
4343
Conditions []dto.FilterVM `json:"conditions"`
44-
Commands []string `json:"commands"`
44+
Commands []dto.FlowCommandVM `json:"commands"`
4545
Active bool `json:"active"`
4646
AgentPlatform string `json:"agent_platform"`
4747
DefaultAgent string `json:"default_agent,omitempty"`

backend/modules/soar/domain/filter.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,23 @@ package domain
33
type OperatorType string
44

55
const (
6-
OperatorIS OperatorType = "IS"
7-
OperatorIsOneOf OperatorType = "IS_ONE_OF"
8-
OperatorIsNotOneOf OperatorType = "IS_NOT_ONE_OF"
6+
OperatorIS OperatorType = "IS"
7+
OperatorISNot OperatorType = "IS_NOT"
8+
9+
OperatorContains OperatorType = "CONTAINS"
10+
OperatorNotContains OperatorType = "NOT_CONTAINS"
11+
12+
OperatorExists OperatorType = "EXISTS"
13+
OperatorNotExists OperatorType = "NOT_EXISTS"
14+
15+
OperatorStartWith OperatorType = "START_WITH"
16+
OperatorNotStartWith OperatorType = "NOT_START_WITH"
17+
18+
OperatorEndsWith OperatorType = "ENDS_WITH"
19+
OperatorNotEndsWith OperatorType = "NOT_ENDS_WITH"
20+
21+
OperatorIsOneOf OperatorType = "IS_ONE_OF"
22+
OperatorIsNotOneOf OperatorType = "IS_NOT_ONE_OF"
923
)
1024

1125
type FilterType struct {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package domain
2+
3+
// Condition names how a flow command joins to the PREVIOUS one when the
4+
// command chain is assembled into a single shell line. The first command in a
5+
// chain carries no Condition (nil).
6+
type Condition string
7+
8+
const (
9+
ConditionOnSuccess Condition = "OnSuccess" // &&
10+
ConditionOnFailure Condition = "OnFailure" // ||
11+
ConditionAlways Condition = "Always" // ;
12+
)
13+
14+
// Operator returns the shell operator for this condition. Unknown values fall
15+
// back to ";" so a malformed flow still runs sequentially rather than erroring.
16+
func (c Condition) Operator() string {
17+
switch c {
18+
case ConditionOnSuccess:
19+
return "&&"
20+
case ConditionOnFailure:
21+
return "||"
22+
default:
23+
return ";"
24+
}
25+
}

backend/modules/soar/dto/rule.go

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,49 +13,57 @@ type FilterVM struct {
1313
Value any `json:"value"`
1414
}
1515

16+
// FlowCommandVM is one step in a flow's command chain. Condition (nil on the
17+
// first entry) is how this command joins to the previous one when the chain
18+
// is concatenated into a single shell line at dispatch time.
19+
type FlowCommandVM struct {
20+
Command string `json:"command" binding:"required"`
21+
Condition *domain.Condition `json:"condition,omitempty" binding:"omitempty,oneof=OnSuccess OnFailure Always"`
22+
}
23+
1624
type CreateRuleRequest struct {
17-
ID *int64 `json:"id"`
18-
Name string `json:"name" binding:"required,max=150"`
19-
Description string `json:"description" binding:"omitempty,max=512"`
20-
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
21-
Commands []string `json:"commands" binding:"required,min=1"`
22-
Active *bool `json:"active" binding:"required"`
23-
AgentPlatform string `json:"agentPlatform" binding:"required"`
24-
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
25-
Shell string `json:"shell" binding:"omitempty,max=20"`
26-
ExcludedAgents []string `json:"excludedAgents"`
25+
ID *int64 `json:"id"`
26+
Name string `json:"name" binding:"required,max=150"`
27+
Description string `json:"description" binding:"omitempty,max=512"`
28+
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
29+
Commands []FlowCommandVM `json:"commands" binding:"required,min=1,dive"`
30+
Active *bool `json:"active" binding:"required"`
31+
AgentPlatform string `json:"agentPlatform" binding:"required"`
32+
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
33+
Shell string `json:"shell" binding:"omitempty,max=20"`
34+
ExcludedAgents []string `json:"excludedAgents"`
2735
}
2836

2937
type UpdateRuleRequest struct {
30-
ID *int64 `json:"id"`
31-
Name string `json:"name" binding:"required,max=150"`
32-
Description string `json:"description" binding:"omitempty,max=512"`
33-
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
34-
Commands []string `json:"commands" binding:"required,min=1"`
35-
Active *bool `json:"active" binding:"required"`
36-
AgentPlatform string `json:"agentPlatform" binding:"required"`
37-
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
38-
Shell string `json:"shell" binding:"omitempty,max=20"`
39-
ExcludedAgents []string `json:"excludedAgents"`
38+
ID *int64 `json:"id"`
39+
Name string `json:"name" binding:"required,max=150"`
40+
Description string `json:"description" binding:"omitempty,max=512"`
41+
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
42+
Commands []FlowCommandVM `json:"commands" binding:"required,min=1,dive"`
43+
Active *bool `json:"active" binding:"required"`
44+
AgentPlatform string `json:"agentPlatform" binding:"required"`
45+
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
46+
Shell string `json:"shell" binding:"omitempty,max=20"`
47+
ExcludedAgents []string `json:"excludedAgents"`
4048
}
4149

4250
type ToggleRuleRequest struct {
4351
Enabled bool `json:"enabled"`
4452
}
4553

4654
type RuleResponse struct {
47-
RelPath string `json:"relPath"`
48-
Name string `json:"name"`
49-
Description string `json:"description,omitempty"`
50-
Conditions []FilterVM `json:"conditions"`
51-
Commands []string `json:"commands"`
52-
Active bool `json:"active"`
53-
AgentPlatform string `json:"agentPlatform,omitempty"`
54-
DefaultAgent string `json:"defaultAgent,omitempty"`
55-
Shell string `json:"shell,omitempty"`
56-
ExcludedAgents []string `json:"excludedAgents,omitempty"`
57-
SystemOwner bool `json:"systemOwner"`
58-
LastModifiedDate *time.Time `json:"lastModifiedDate,omitempty"`
55+
RelPath string `json:"relPath"`
56+
Name string `json:"name"`
57+
Description string `json:"description,omitempty"`
58+
Conditions []FilterVM `json:"conditions"`
59+
Commands []FlowCommandVM `json:"commands"`
60+
Active bool `json:"active"`
61+
AgentPlatform string `json:"agentPlatform,omitempty"`
62+
DefaultAgent string `json:"defaultAgent,omitempty"`
63+
Shell string `json:"shell,omitempty"`
64+
ExcludedAgents []string `json:"excludedAgents,omitempty"`
65+
SystemOwner bool `json:"systemOwner"`
66+
LastModifiedDate *time.Time `json:"lastModifiedDate,omitempty"`
5967
}
6068

6169
type RuleFilters struct {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package usecase
2+
3+
import (
4+
"testing"
5+
6+
"gopkg.in/yaml.v3"
7+
8+
"github.com/utmstack/utmstack/backend/modules/soar/domain"
9+
)
10+
11+
func TestFlowCommandUnmarshalYAML_LegacyString(t *testing.T) {
12+
src := []byte("commands:\n - net user \"$(x)\" /active:no\n - {command: \"echo b\", condition: OnSuccess}\n")
13+
var wrap struct {
14+
Commands []FlowCommand `yaml:"commands"`
15+
}
16+
if err := yaml.Unmarshal(src, &wrap); err != nil {
17+
t.Fatalf("unmarshal: %v", err)
18+
}
19+
if len(wrap.Commands) != 2 || wrap.Commands[0].Command != `net user "$(x)" /active:no` || wrap.Commands[0].Condition != nil {
20+
t.Fatalf("bare-string entry not decoded: %+v", wrap.Commands)
21+
}
22+
if wrap.Commands[1].Command != "echo b" || wrap.Commands[1].Condition == nil || *wrap.Commands[1].Condition != domain.ConditionOnSuccess {
23+
t.Fatalf("mapping entry not decoded: %+v", wrap.Commands[1])
24+
}
25+
}
26+
27+
func TestAssembleChain(t *testing.T) {
28+
ok := domain.ConditionOnSuccess
29+
fail := domain.ConditionOnFailure
30+
always := domain.ConditionAlways
31+
32+
cases := []struct {
33+
name string
34+
in []FlowCommand
35+
want string
36+
}{
37+
{"empty", nil, ""},
38+
{"single command drops leading condition",
39+
[]FlowCommand{{Command: "a", Condition: &ok}},
40+
"a"},
41+
{"chain uses each entry's condition as the joiner from the previous",
42+
[]FlowCommand{{Command: "a"}, {Command: "b", Condition: &ok}, {Command: "c", Condition: &fail}, {Command: "d", Condition: &always}},
43+
"a && b || c ; d"},
44+
{"nil condition on non-first defaults to ;",
45+
[]FlowCommand{{Command: "a"}, {Command: "b"}},
46+
"a ; b"},
47+
{"empty commands are skipped without leaving stray operators",
48+
[]FlowCommand{{Command: "a"}, {Command: "", Condition: &ok}, {Command: "c", Condition: &fail}},
49+
"a || c"},
50+
}
51+
for _, tc := range cases {
52+
if got := assembleChain(tc.in); got != tc.want {
53+
t.Errorf("%s: got %q want %q", tc.name, got, tc.want)
54+
}
55+
}
56+
}

backend/modules/soar/usecase/execution.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,48 @@ func (u *executionUsecase) HandleMatch(ctx context.Context, req dto.MatchRequest
4444
}
4545

4646
alertID := gjson.Get(alertJSON, "id").String()
47-
enqueued := false
48-
for _, raw := range flow.Commands {
49-
command := buildCommand(raw, alertJSON)
50-
if _, err := u.repo.Create(ctx, &domain.AlertResponseRuleExecution{
51-
RulePath: req.RulePath,
52-
AlertID: alertID,
53-
Command: command,
54-
Agent: target,
55-
ExecutionStatus: domain.ExecutionStatusPending,
56-
}); err != nil {
57-
_ = catcher.Error("soar: failed to enqueue execution", err, map[string]any{"rule": req.RulePath, "alert": alertID})
58-
continue
59-
}
60-
enqueued = true
47+
command := buildCommand(assembleChain(flow.Commands), alertJSON)
48+
if command == "" {
49+
return nil
50+
}
51+
if _, err := u.repo.Create(ctx, &domain.AlertResponseRuleExecution{
52+
RulePath: req.RulePath,
53+
AlertID: alertID,
54+
Command: command,
55+
Agent: target,
56+
ExecutionStatus: domain.ExecutionStatusPending,
57+
}); err != nil {
58+
return catcher.Error("soar: failed to enqueue execution", err, map[string]any{"rule": req.RulePath, "alert": alertID})
6159
}
62-
if enqueued && u.notify != nil {
60+
if u.notify != nil {
6361
u.notify()
6462
}
6563
return nil
6664
}
6765

66+
// assembleChain joins flow commands into one shell line using each entry's
67+
// condition as the operator between it and the previous command. The first
68+
// entry's condition is ignored (there's nothing to join it to).
69+
func assembleChain(cmds []FlowCommand) string {
70+
var b strings.Builder
71+
for i, c := range cmds {
72+
if c.Command == "" {
73+
continue
74+
}
75+
if b.Len() > 0 {
76+
op := domain.ConditionAlways.Operator()
77+
if i > 0 && c.Condition != nil {
78+
op = c.Condition.Operator()
79+
}
80+
b.WriteByte(' ')
81+
b.WriteString(op)
82+
b.WriteByte(' ')
83+
}
84+
b.WriteString(c.Command)
85+
}
86+
return b.String()
87+
}
88+
6889
func (u *executionUsecase) resolveAgent(ctx context.Context, flow Flow, alertJSON string) (string, error) {
6990
src := gjson.Get(alertJSON, "dataSource").String()
7091
if agentInList(flow.ExcludedAgents, src) {

backend/modules/soar/usecase/flow_bootstrap.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,17 @@ func legacyToFlow(row *domain.AlertResponseRule) Flow {
237237
}
238238
}
239239

240-
var commands []string
240+
var commands []FlowCommand
241241
seen := make(map[string]bool)
242+
always := domain.ConditionAlways
242243
addCmd := func(c string) {
243244
if c = strings.TrimSpace(c); c != "" && !seen[c] {
244245
seen[c] = true
245-
commands = append(commands, c)
246+
fc := FlowCommand{Command: c}
247+
if len(commands) > 0 {
248+
fc.Condition = &always
249+
}
250+
commands = append(commands, fc)
246251
}
247252
}
248253
addCmd(row.RuleCmd)

backend/modules/soar/usecase/flow_model.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package usecase
33
import (
44
"errors"
55
"time"
6+
7+
"gopkg.in/yaml.v3"
8+
9+
"github.com/utmstack/utmstack/backend/modules/soar/domain"
610
)
711

812
var (
@@ -16,11 +20,36 @@ type FlowCondition struct {
1620
Value any `yaml:"value"`
1721
}
1822

23+
// FlowCommand is one step in a flow's command chain. Condition (nil on the
24+
// first entry) is how this command joins to the previous one when the chain
25+
// is concatenated into a single shell line.
26+
type FlowCommand struct {
27+
Command string `yaml:"command"`
28+
Condition *domain.Condition `yaml:"condition,omitempty"`
29+
}
30+
31+
// UnmarshalYAML accepts either a bare string (legacy `commands: [cmd, cmd]`)
32+
// or a mapping `{command, condition}`. Bare strings decode with a nil
33+
// Condition; the loader treats that as Always when it's not the first step.
34+
func (fc *FlowCommand) UnmarshalYAML(value *yaml.Node) error {
35+
if value.Kind == yaml.ScalarNode {
36+
fc.Command = value.Value
37+
return nil
38+
}
39+
type raw FlowCommand
40+
var r raw
41+
if err := value.Decode(&r); err != nil {
42+
return err
43+
}
44+
*fc = FlowCommand(r)
45+
return nil
46+
}
47+
1948
type Flow struct {
2049
Name string `yaml:"name"`
2150
Description string `yaml:"description,omitempty"`
2251
Conditions []FlowCondition `yaml:"conditions"`
23-
Commands []string `yaml:"commands"`
52+
Commands []FlowCommand `yaml:"commands"`
2453
Shell string `yaml:"shell,omitempty"`
2554
AgentPlatform string `yaml:"agentPlatform,omitempty"`
2655
DefaultAgent string `yaml:"defaultAgent,omitempty"`

backend/modules/soar/usecase/flow_store.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"sync"
1010
"time"
11+
12+
"github.com/threatwinds/go-sdk/catcher"
1113
)
1214

1315
type FlowListFilter struct {
@@ -98,6 +100,7 @@ func loadFlowOverlay(dir string, system bool) ([]*StoredFlow, error) {
98100

99101
flow, ferr := readFlowFile(path)
100102
if ferr != nil {
103+
_ = catcher.Error("soar: skipping unparsable flow file", ferr, map[string]any{"path": path})
101104
return nil // skip unparsable files rather than failing the whole load
102105
}
103106

0 commit comments

Comments
 (0)