Skip to content
Open
Show file tree
Hide file tree
Changes from all 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