Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/modules/mcp/tools_soar.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type soarRuleCreateInput struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Conditions []dto.FilterVM `json:"conditions"`
Commands []string `json:"commands"`
Commands []dto.FlowCommandVM `json:"commands"`
Active bool `json:"active"`
AgentPlatform string `json:"agent_platform"`
DefaultAgent string `json:"default_agent,omitempty"`
Expand All @@ -41,7 +41,7 @@ type soarRuleUpdateInput struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Conditions []dto.FilterVM `json:"conditions"`
Commands []string `json:"commands"`
Commands []dto.FlowCommandVM `json:"commands"`
Active bool `json:"active"`
AgentPlatform string `json:"agent_platform"`
DefaultAgent string `json:"default_agent,omitempty"`
Expand Down
20 changes: 17 additions & 3 deletions backend/modules/soar/domain/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,23 @@ package domain
type OperatorType string

const (
OperatorIS OperatorType = "IS"
OperatorIsOneOf OperatorType = "IS_ONE_OF"
OperatorIsNotOneOf OperatorType = "IS_NOT_ONE_OF"
OperatorIS OperatorType = "IS"
OperatorISNot OperatorType = "IS_NOT"

OperatorContains OperatorType = "CONTAINS"
OperatorNotContains OperatorType = "NOT_CONTAINS"

OperatorExists OperatorType = "EXISTS"
OperatorNotExists OperatorType = "NOT_EXISTS"

OperatorStartWith OperatorType = "START_WITH"
OperatorNotStartWith OperatorType = "NOT_START_WITH"

OperatorEndsWith OperatorType = "ENDS_WITH"
OperatorNotEndsWith OperatorType = "NOT_ENDS_WITH"

OperatorIsOneOf OperatorType = "IS_ONE_OF"
OperatorIsNotOneOf OperatorType = "IS_NOT_ONE_OF"
)

type FilterType struct {
Expand Down
25 changes: 25 additions & 0 deletions backend/modules/soar/domain/flow_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package domain

// Condition names how a flow command joins to the PREVIOUS one when the
// command chain is assembled into a single shell line. The first command in a
// chain carries no Condition (nil).
type Condition string

const (
ConditionOnSuccess Condition = "OnSuccess" // &&
ConditionOnFailure Condition = "OnFailure" // ||
ConditionAlways Condition = "Always" // ;
)

// Operator returns the shell operator for this condition. Unknown values fall
// back to ";" so a malformed flow still runs sequentially rather than erroring.
func (c Condition) Operator() string {
switch c {
case ConditionOnSuccess:
return "&&"
case ConditionOnFailure:
return "||"
default:
return ";"
}
}
72 changes: 40 additions & 32 deletions backend/modules/soar/dto/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,57 @@ type FilterVM struct {
Value any `json:"value"`
}

// FlowCommandVM is one step in a flow's command chain. Condition (nil on the
// first entry) is how this command joins to the previous one when the chain
// is concatenated into a single shell line at dispatch time.
type FlowCommandVM struct {
Command string `json:"command" binding:"required"`
Condition *domain.Condition `json:"condition,omitempty" binding:"omitempty,oneof=OnSuccess OnFailure Always"`
}

type CreateRuleRequest struct {
ID *int64 `json:"id"`
Name string `json:"name" binding:"required,max=150"`
Description string `json:"description" binding:"omitempty,max=512"`
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
Commands []string `json:"commands" binding:"required,min=1"`
Active *bool `json:"active" binding:"required"`
AgentPlatform string `json:"agentPlatform" binding:"required"`
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
Shell string `json:"shell" binding:"omitempty,max=20"`
ExcludedAgents []string `json:"excludedAgents"`
ID *int64 `json:"id"`
Name string `json:"name" binding:"required,max=150"`
Description string `json:"description" binding:"omitempty,max=512"`
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
Commands []FlowCommandVM `json:"commands" binding:"required,min=1,dive"`
Active *bool `json:"active" binding:"required"`
AgentPlatform string `json:"agentPlatform" binding:"required"`
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
Shell string `json:"shell" binding:"omitempty,max=20"`
ExcludedAgents []string `json:"excludedAgents"`
}

type UpdateRuleRequest struct {
ID *int64 `json:"id"`
Name string `json:"name" binding:"required,max=150"`
Description string `json:"description" binding:"omitempty,max=512"`
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
Commands []string `json:"commands" binding:"required,min=1"`
Active *bool `json:"active" binding:"required"`
AgentPlatform string `json:"agentPlatform" binding:"required"`
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
Shell string `json:"shell" binding:"omitempty,max=20"`
ExcludedAgents []string `json:"excludedAgents"`
ID *int64 `json:"id"`
Name string `json:"name" binding:"required,max=150"`
Description string `json:"description" binding:"omitempty,max=512"`
Conditions []FilterVM `json:"conditions" binding:"required,min=1"`
Commands []FlowCommandVM `json:"commands" binding:"required,min=1,dive"`
Active *bool `json:"active" binding:"required"`
AgentPlatform string `json:"agentPlatform" binding:"required"`
DefaultAgent string `json:"defaultAgent" binding:"omitempty,max=500"`
Shell string `json:"shell" binding:"omitempty,max=20"`
ExcludedAgents []string `json:"excludedAgents"`
}

type ToggleRuleRequest struct {
Enabled bool `json:"enabled"`
}

type RuleResponse struct {
RelPath string `json:"relPath"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Conditions []FilterVM `json:"conditions"`
Commands []string `json:"commands"`
Active bool `json:"active"`
AgentPlatform string `json:"agentPlatform,omitempty"`
DefaultAgent string `json:"defaultAgent,omitempty"`
Shell string `json:"shell,omitempty"`
ExcludedAgents []string `json:"excludedAgents,omitempty"`
SystemOwner bool `json:"systemOwner"`
LastModifiedDate *time.Time `json:"lastModifiedDate,omitempty"`
RelPath string `json:"relPath"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Conditions []FilterVM `json:"conditions"`
Commands []FlowCommandVM `json:"commands"`
Active bool `json:"active"`
AgentPlatform string `json:"agentPlatform,omitempty"`
DefaultAgent string `json:"defaultAgent,omitempty"`
Shell string `json:"shell,omitempty"`
ExcludedAgents []string `json:"excludedAgents,omitempty"`
SystemOwner bool `json:"systemOwner"`
LastModifiedDate *time.Time `json:"lastModifiedDate,omitempty"`
}

type RuleFilters struct {
Expand Down
56 changes: 56 additions & 0 deletions backend/modules/soar/usecase/assemble_chain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package usecase

import (
"testing"

"gopkg.in/yaml.v3"

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

func TestFlowCommandUnmarshalYAML_LegacyString(t *testing.T) {
src := []byte("commands:\n - net user \"$(x)\" /active:no\n - {command: \"echo b\", condition: OnSuccess}\n")
var wrap struct {
Commands []FlowCommand `yaml:"commands"`
}
if err := yaml.Unmarshal(src, &wrap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(wrap.Commands) != 2 || wrap.Commands[0].Command != `net user "$(x)" /active:no` || wrap.Commands[0].Condition != nil {
t.Fatalf("bare-string entry not decoded: %+v", wrap.Commands)
}
if wrap.Commands[1].Command != "echo b" || wrap.Commands[1].Condition == nil || *wrap.Commands[1].Condition != domain.ConditionOnSuccess {
t.Fatalf("mapping entry not decoded: %+v", wrap.Commands[1])
}
}

func TestAssembleChain(t *testing.T) {
ok := domain.ConditionOnSuccess
fail := domain.ConditionOnFailure
always := domain.ConditionAlways

cases := []struct {
name string
in []FlowCommand
want string
}{
{"empty", nil, ""},
{"single command drops leading condition",
[]FlowCommand{{Command: "a", Condition: &ok}},
"a"},
{"chain uses each entry's condition as the joiner from the previous",
[]FlowCommand{{Command: "a"}, {Command: "b", Condition: &ok}, {Command: "c", Condition: &fail}, {Command: "d", Condition: &always}},
"a && b || c ; d"},
{"nil condition on non-first defaults to ;",
[]FlowCommand{{Command: "a"}, {Command: "b"}},
"a ; b"},
{"empty commands are skipped without leaving stray operators",
[]FlowCommand{{Command: "a"}, {Command: "", Condition: &ok}, {Command: "c", Condition: &fail}},
"a || c"},
}
for _, tc := range cases {
if got := assembleChain(tc.in); got != tc.want {
t.Errorf("%s: got %q want %q", tc.name, got, tc.want)
}
}
}
51 changes: 36 additions & 15 deletions backend/modules/soar/usecase/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,48 @@ func (u *executionUsecase) HandleMatch(ctx context.Context, req dto.MatchRequest
}

alertID := gjson.Get(alertJSON, "id").String()
enqueued := false
for _, raw := range flow.Commands {
command := buildCommand(raw, alertJSON)
if _, err := u.repo.Create(ctx, &domain.AlertResponseRuleExecution{
RulePath: req.RulePath,
AlertID: alertID,
Command: command,
Agent: target,
ExecutionStatus: domain.ExecutionStatusPending,
}); err != nil {
_ = catcher.Error("soar: failed to enqueue execution", err, map[string]any{"rule": req.RulePath, "alert": alertID})
continue
}
enqueued = true
command := buildCommand(assembleChain(flow.Commands), alertJSON)
if command == "" {
return nil
}
if _, err := u.repo.Create(ctx, &domain.AlertResponseRuleExecution{
RulePath: req.RulePath,
AlertID: alertID,
Command: command,
Agent: target,
ExecutionStatus: domain.ExecutionStatusPending,
}); err != nil {
return catcher.Error("soar: failed to enqueue execution", err, map[string]any{"rule": req.RulePath, "alert": alertID})
}
if enqueued && u.notify != nil {
if u.notify != nil {
u.notify()
}
return nil
}

// assembleChain joins flow commands into one shell line using each entry's
// condition as the operator between it and the previous command. The first
// entry's condition is ignored (there's nothing to join it to).
func assembleChain(cmds []FlowCommand) string {
var b strings.Builder
for i, c := range cmds {
if c.Command == "" {
continue
}
if b.Len() > 0 {
op := domain.ConditionAlways.Operator()
if i > 0 && c.Condition != nil {
op = c.Condition.Operator()
}
b.WriteByte(' ')
b.WriteString(op)
b.WriteByte(' ')
}
b.WriteString(c.Command)
}
return b.String()
}

func (u *executionUsecase) resolveAgent(ctx context.Context, flow Flow, alertJSON string) (string, error) {
src := gjson.Get(alertJSON, "dataSource").String()
if agentInList(flow.ExcludedAgents, src) {
Expand Down
9 changes: 7 additions & 2 deletions backend/modules/soar/usecase/flow_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,17 @@ func legacyToFlow(row *domain.AlertResponseRule) Flow {
}
}

var commands []string
var commands []FlowCommand
seen := make(map[string]bool)
always := domain.ConditionAlways
addCmd := func(c string) {
if c = strings.TrimSpace(c); c != "" && !seen[c] {
seen[c] = true
commands = append(commands, c)
fc := FlowCommand{Command: c}
if len(commands) > 0 {
fc.Condition = &always
}
commands = append(commands, fc)
}
}
addCmd(row.RuleCmd)
Expand Down
31 changes: 30 additions & 1 deletion backend/modules/soar/usecase/flow_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package usecase
import (
"errors"
"time"

"gopkg.in/yaml.v3"

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

var (
Expand All @@ -16,11 +20,36 @@ type FlowCondition struct {
Value any `yaml:"value"`
}

// FlowCommand is one step in a flow's command chain. Condition (nil on the
// first entry) is how this command joins to the previous one when the chain
// is concatenated into a single shell line.
type FlowCommand struct {
Command string `yaml:"command"`
Condition *domain.Condition `yaml:"condition,omitempty"`
}

// UnmarshalYAML accepts either a bare string (legacy `commands: [cmd, cmd]`)
// or a mapping `{command, condition}`. Bare strings decode with a nil
// Condition; the loader treats that as Always when it's not the first step.
func (fc *FlowCommand) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
fc.Command = value.Value
return nil
}
type raw FlowCommand
var r raw
if err := value.Decode(&r); err != nil {
return err
}
*fc = FlowCommand(r)
return nil
}

type Flow struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Conditions []FlowCondition `yaml:"conditions"`
Commands []string `yaml:"commands"`
Commands []FlowCommand `yaml:"commands"`
Shell string `yaml:"shell,omitempty"`
AgentPlatform string `yaml:"agentPlatform,omitempty"`
DefaultAgent string `yaml:"defaultAgent,omitempty"`
Expand Down
3 changes: 3 additions & 0 deletions backend/modules/soar/usecase/flow_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"

"github.com/threatwinds/go-sdk/catcher"
)

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

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

Expand Down
Loading
Loading