diff --git a/backend/modules/mcp/tools_soar.go b/backend/modules/mcp/tools_soar.go index 9dc30cfd5..1256e5a9d 100644 --- a/backend/modules/mcp/tools_soar.go +++ b/backend/modules/mcp/tools_soar.go @@ -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"` @@ -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"` diff --git a/backend/modules/soar/domain/filter.go b/backend/modules/soar/domain/filter.go index 25ece3d95..4325706e4 100644 --- a/backend/modules/soar/domain/filter.go +++ b/backend/modules/soar/domain/filter.go @@ -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 { diff --git a/backend/modules/soar/domain/flow_command.go b/backend/modules/soar/domain/flow_command.go new file mode 100644 index 000000000..0d0ca293d --- /dev/null +++ b/backend/modules/soar/domain/flow_command.go @@ -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 ";" + } +} diff --git a/backend/modules/soar/dto/rule.go b/backend/modules/soar/dto/rule.go index 5740491f2..a86c0e7ed 100644 --- a/backend/modules/soar/dto/rule.go +++ b/backend/modules/soar/dto/rule.go @@ -13,30 +13,38 @@ 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 { @@ -44,18 +52,18 @@ type ToggleRuleRequest struct { } 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 { diff --git a/backend/modules/soar/usecase/assemble_chain_test.go b/backend/modules/soar/usecase/assemble_chain_test.go new file mode 100644 index 000000000..c376ec148 --- /dev/null +++ b/backend/modules/soar/usecase/assemble_chain_test.go @@ -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) + } + } +} diff --git a/backend/modules/soar/usecase/execution.go b/backend/modules/soar/usecase/execution.go index 2afcab82d..f29dd1460 100644 --- a/backend/modules/soar/usecase/execution.go +++ b/backend/modules/soar/usecase/execution.go @@ -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) { diff --git a/backend/modules/soar/usecase/flow_bootstrap.go b/backend/modules/soar/usecase/flow_bootstrap.go index 036ed77a1..fee49a246 100644 --- a/backend/modules/soar/usecase/flow_bootstrap.go +++ b/backend/modules/soar/usecase/flow_bootstrap.go @@ -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) diff --git a/backend/modules/soar/usecase/flow_model.go b/backend/modules/soar/usecase/flow_model.go index c11544704..6c7dffac2 100644 --- a/backend/modules/soar/usecase/flow_model.go +++ b/backend/modules/soar/usecase/flow_model.go @@ -3,6 +3,10 @@ package usecase import ( "errors" "time" + + "gopkg.in/yaml.v3" + + "github.com/utmstack/utmstack/backend/modules/soar/domain" ) var ( @@ -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"` diff --git a/backend/modules/soar/usecase/flow_store.go b/backend/modules/soar/usecase/flow_store.go index 5221690fc..2fec73e56 100644 --- a/backend/modules/soar/usecase/flow_store.go +++ b/backend/modules/soar/usecase/flow_store.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/threatwinds/go-sdk/catcher" ) type FlowListFilter struct { @@ -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 } diff --git a/backend/modules/soar/usecase/rule.go b/backend/modules/soar/usecase/rule.go index 1fa3c6852..5c4ce7549 100644 --- a/backend/modules/soar/usecase/rule.go +++ b/backend/modules/soar/usecase/rule.go @@ -110,12 +110,12 @@ func mapStoreErr(err error) error { } } -func requestToFlow(name, description string, conds []dto.FilterVM, commands []string, shell, agentPlatform, defaultAgent string, excludedAgents []string) Flow { +func requestToFlow(name, description string, conds []dto.FilterVM, commands []dto.FlowCommandVM, shell, agentPlatform, defaultAgent string, excludedAgents []string) Flow { return Flow{ Name: name, Description: description, Conditions: toFlowConditions(conds), - Commands: commands, + Commands: toFlowCommands(commands), Shell: shell, AgentPlatform: agentPlatform, DefaultAgent: defaultAgent, @@ -131,6 +131,22 @@ func toFlowConditions(vms []dto.FilterVM) []FlowCondition { return out } +func toFlowCommands(vms []dto.FlowCommandVM) []FlowCommand { + out := make([]FlowCommand, 0, len(vms)) + for _, v := range vms { + out = append(out, FlowCommand{Command: v.Command, Condition: v.Condition}) + } + return out +} + +func flowCommandsToVMs(cmds []FlowCommand) []dto.FlowCommandVM { + out := make([]dto.FlowCommandVM, 0, len(cmds)) + for _, c := range cmds { + out = append(out, dto.FlowCommandVM{Command: c.Command, Condition: c.Condition}) + } + return out +} + func storedFlowToResponse(sf *StoredFlow) *dto.RuleResponse { if sf == nil { return nil @@ -139,7 +155,7 @@ func storedFlowToResponse(sf *StoredFlow) *dto.RuleResponse { RelPath: sf.RelPath, Name: sf.Name, Description: sf.Description, - Commands: sf.Commands, + Commands: flowCommandsToVMs(sf.Commands), Active: sf.Active(), AgentPlatform: sf.AgentPlatform, DefaultAgent: sf.DefaultAgent, diff --git a/frontend/src/features/soar/components/FlowEditor.tsx b/frontend/src/features/soar/components/FlowEditor.tsx index 8d52d2611..2e947b1f0 100644 --- a/frontend/src/features/soar/components/FlowEditor.tsx +++ b/frontend/src/features/soar/components/FlowEditor.tsx @@ -11,7 +11,7 @@ import { VariablesManager } from '@/features/datasources/components/VariablesMan import { soarFlowsService, SoarHttpError } from '../services/soar-flows.service' import { flowToForm, formToInput, flowFormToYaml, yamlToFlowForm, type FlowFormState } from '../lib/flow-yaml' import { ALERT_FIELDS, COMMON_PLATFORMS, defaultShellForPlatform, shellsForPlatform } from '../lib/alert-fields' -import { SOAR_OPERATORS, type Flow, type FlowCondition, type SoarOperator } from '../types/soar.types' +import { SOAR_MULTI_VALUE_OPERATORS, SOAR_NO_VALUE_OPERATORS, SOAR_OPERATORS, type Flow, type FlowCommand, type FlowCondition, type SoarCondition, type SoarOperator } from '../types/soar.types' const SELECT = 'h-8 rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' @@ -381,7 +381,9 @@ function ConditionsEditor({ ))} - setAt(i, { value: e.target.value })} placeholder={c.operator === 'IS' ? t('soar.editor.value') : t('soar.editor.valueList')} className="h-8 min-w-[140px] flex-1 font-mono text-xs" /> + {!SOAR_NO_VALUE_OPERATORS.includes(c.operator) && ( + setAt(i, { value: e.target.value })} placeholder={SOAR_MULTI_VALUE_OPERATORS.includes(c.operator) ? t('soar.editor.valueList') : t('soar.editor.value')} className="h-8 min-w-[140px] flex-1 font-mono text-xs" /> + )} {!readOnly && ( +
+ {i > 0 && ( +
+
+ +
)} +
+ {i + 1} + { + refs.current[i] = el + }} + value={cmd.command} + readOnly={readOnly} + onFocus={() => setActive(i)} + onClick={() => setActive(i)} + onChange={(e) => onChange(commands.map((x, k) => (k === i ? { ...x, command: e.target.value } : x)))} + placeholder='net user "$(target.user)" /active:no' + className="h-8 flex-1 font-mono text-xs" + /> + {!readOnly && ( + + )} +
))}
{!readOnly && ( - )} diff --git a/frontend/src/features/soar/components/TemplatePicker.tsx b/frontend/src/features/soar/components/TemplatePicker.tsx new file mode 100644 index 000000000..deb5bfe62 --- /dev/null +++ b/frontend/src/features/soar/components/TemplatePicker.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, FileText, Loader2, Lock, X } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { soarTemplatesService } from '../services/soar-templates.service' +import type { ActionTemplate } from '../types/soar.types' + +const PAGE_SIZE = 20 + +export function TemplatePicker({ + onPick, + onScratch, + onClose, +}: { + onPick: (t: ActionTemplate) => void + onScratch: () => void + onClose: () => void +}) { + const { t } = useTranslation() + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + const loadPage = useCallback(async (p: number) => { + setLoading(true) + setError(false) + try { + const res = await soarTemplatesService.list({ page: p, size: PAGE_SIZE }) + setTotal(res.total) + setItems((prev) => (p === 0 ? res.data : [...prev, ...res.data])) + } catch { + setError(true) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadPage(page) + }, [page, loadPage]) + + return ( +
+
e.stopPropagation()}> +
+
+

{t('soar.templates.title')}

+

{t('soar.templates.subtitle')}

+
+ +
+ +
+ {loading && items.length === 0 ? ( +
+ {t('soar.templates.loading')} +
+ ) : error && items.length === 0 ? ( +
+ {t('soar.templates.loadError')} + +
+ ) : items.length === 0 ? ( +
{t('soar.templates.empty')}
+ ) : ( +
+ {items.map((tpl) => ( + + ))} + setPage((p) => p + 1)} + hasMore={items.length < total} + loading={loading} + endLabel={t('common.allLoaded', { count: total })} + /> +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/soar/lib/flow-yaml.ts b/frontend/src/features/soar/lib/flow-yaml.ts index e84b381ae..d96c01781 100644 --- a/frontend/src/features/soar/lib/flow-yaml.ts +++ b/frontend/src/features/soar/lib/flow-yaml.ts @@ -1,5 +1,5 @@ import { dump, load } from 'js-yaml' -import type { Flow, FlowCondition, SaveFlowInput, SoarOperator } from '../types/soar.types' +import { SOAR_CONDITIONS, SOAR_MULTI_VALUE_OPERATORS, SOAR_NO_VALUE_OPERATORS, SOAR_OPERATORS, type Flow, type FlowCommand, type FlowCondition, type SaveFlowInput, type SoarCondition, type SoarOperator } from '../types/soar.types' /** Structured editing state for a flow. `active` is managed by the toggle (not in * the YAML file), so callers preserve it across the code round-trip. */ @@ -7,7 +7,7 @@ export interface FlowFormState { name: string description: string conditions: FlowCondition[] - commands: string[] + commands: FlowCommand[] shell: string agentPlatform: string defaultAgent: string @@ -23,26 +23,39 @@ function isRecord(v: unknown): v is Record { const str = (v: unknown): string => (v == null ? '' : String(v)) const strArray = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []) +/** Accept either the object form `{command, condition?}` or a legacy bare + * string (older YAML files). Unknown/invalid condition values are dropped. */ +function parseCommand(c: unknown): FlowCommand { + if (typeof c === 'string') return { command: c } + if (!isRecord(c)) return { command: '' } + const command = str(c.command) + const cond = str(c.condition) + return SOAR_CONDITIONS.includes(cond as SoarCondition) ? { command, condition: cond as SoarCondition } : { command } +} + function parseCond(c: unknown): FlowCondition { const o = isRecord(c) ? c : {} - const op = (['IS', 'IS_ONE_OF', 'IS_NOT_ONE_OF'].includes(str(o.operator)) ? o.operator : 'IS') as SoarOperator + const op = (SOAR_OPERATORS.includes(str(o.operator) as SoarOperator) ? o.operator : 'IS') as SoarOperator return { operator: op, field: str(o.field), value: o.value } } /** Coerce a condition's value to the right shape for its operator. */ function normalizeCond(c: FlowCondition): FlowCondition { const field = c.field.trim() - if (c.operator === 'IS') { - const v = Array.isArray(c.value) ? c.value[0] : c.value - return { operator: 'IS', field, value: str(v) } + if (SOAR_NO_VALUE_OPERATORS.includes(c.operator)) { + return { operator: c.operator, field, value: '' } + } + if (SOAR_MULTI_VALUE_OPERATORS.includes(c.operator)) { + const arr = Array.isArray(c.value) + ? c.value.map(String) + : str(c.value) + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return { operator: c.operator, field, value: arr } } - const arr = Array.isArray(c.value) - ? c.value.map(String) - : str(c.value) - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return { operator: c.operator, field, value: arr } + const v = Array.isArray(c.value) ? c.value[0] : c.value + return { operator: c.operator, field, value: str(v) } } export function flowToForm(f?: Flow): FlowFormState { @@ -50,7 +63,7 @@ export function flowToForm(f?: Flow): FlowFormState { name: f?.name ?? '', description: f?.description ?? '', conditions: f?.conditions?.length ? f.conditions.map((c) => ({ ...c })) : [{ operator: 'IS', field: '', value: '' }], - commands: f?.commands?.length ? [...f.commands] : [''], + commands: f?.commands?.length ? f.commands.map((c) => ({ ...c })) : [{ command: '' }], shell: f?.shell ?? '', agentPlatform: f?.agentPlatform ?? '', defaultAgent: f?.defaultAgent ?? '', @@ -60,11 +73,15 @@ export function flowToForm(f?: Flow): FlowFormState { } export function formToInput(f: FlowFormState): SaveFlowInput { + const commands = f.commands + .map((c) => ({ ...c, command: c.command.trim() })) + .filter((c) => c.command) + .map((c, i) => (i === 0 ? { command: c.command } : { command: c.command, condition: c.condition ?? 'Always' })) return { name: f.name.trim(), description: f.description.trim(), conditions: f.conditions.filter((c) => c.field.trim()).map(normalizeCond), - commands: f.commands.map((c) => c.trim()).filter(Boolean), + commands, active: f.active, agentPlatform: f.agentPlatform.trim(), defaultAgent: f.defaultAgent.trim(), @@ -100,12 +117,12 @@ export function yamlToFlowForm(content: string): FlowParseResult { if (!isRecord(raw)) return { ok: false, error: 'root must be a flow mapping' } const conditions = Array.isArray(raw.conditions) ? raw.conditions.map(parseCond) : [] - const commands = Array.isArray(raw.commands) ? raw.commands.map(String) : [] + const commands = Array.isArray(raw.commands) ? raw.commands.map(parseCommand) : [] const form: FlowFormState = { name: str(raw.name), description: str(raw.description), conditions: conditions.length ? conditions : [{ operator: 'IS', field: '', value: '' }], - commands: commands.length ? commands : [''], + commands: commands.length ? commands : [{ command: '' }], shell: str(raw.shell), agentPlatform: str(raw.agentPlatform), defaultAgent: str(raw.defaultAgent), diff --git a/frontend/src/features/soar/pages/FlowsPage.tsx b/frontend/src/features/soar/pages/FlowsPage.tsx index b88a93e00..bd998fb2f 100644 --- a/frontend/src/features/soar/pages/FlowsPage.tsx +++ b/frontend/src/features/soar/pages/FlowsPage.tsx @@ -9,8 +9,9 @@ import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { soarFlowsService } from '../services/soar-flows.service' import { soarExecutionsService } from '../services/soar-executions.service' import { FlowEditor } from '../components/FlowEditor' +import { TemplatePicker } from '../components/TemplatePicker' import { ExecutionsView } from '../components/ExecutionsView' -import type { Flow } from '../types/soar.types' +import type { ActionTemplate, Flow } from '../types/soar.types' type PageTab = 'flows' | 'executions' @@ -85,8 +86,27 @@ function FlowsTab() { const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [editing, setEditing] = useState<{ flow?: Flow; creating: boolean } | null>(null) + const [pickingTemplate, setPickingTemplate] = useState(false) const [stats, setStats] = useState>({}) + const startFromTemplate = (tpl: ActionTemplate) => { + const seed: Flow = { + relPath: '', + name: tpl.title, + description: tpl.description ?? '', + conditions: [], + commands: [{ command: tpl.command }], + active: true, + agentPlatform: '', + defaultAgent: '', + shell: '', + excludedAgents: [], + systemOwner: false, + } + setPickingTemplate(false) + setEditing({ flow: seed, creating: true }) + } + useEffect(() => { const h = setTimeout(() => { setDebounced(search.trim()) @@ -186,7 +206,7 @@ function FlowsTab() {
-
@@ -239,6 +259,17 @@ function FlowsTab() { }} /> )} + + {pickingTemplate && ( + { + setPickingTemplate(false) + setEditing({ creating: true }) + }} + onClose={() => setPickingTemplate(false)} + /> + )} ) } diff --git a/frontend/src/features/soar/services/soar-templates.service.ts b/frontend/src/features/soar/services/soar-templates.service.ts new file mode 100644 index 000000000..bc100e261 --- /dev/null +++ b/frontend/src/features/soar/services/soar-templates.service.ts @@ -0,0 +1,15 @@ +import { createApiClient } from '@/shared/lib/api-client' +import type { ActionTemplate, ActionTemplateListQuery } from '../types/soar.types' + +const api = createApiClient() +const BASE = '/soar/action-templates' + +export const soarTemplatesService = { + // Returns { data: ActionTemplate[], total } — total from X-Total-Count. + list: (q: ActionTemplateListQuery = {}) => { + const p = new URLSearchParams() + p.set('page', String(q.page ?? 0)) + p.set('size', String(q.size ?? 20)) + return api.getPaged(`${BASE}?${p.toString()}`) + }, +} diff --git a/frontend/src/features/soar/types/soar.types.ts b/frontend/src/features/soar/types/soar.types.ts index dc81fe003..b6c5fea3d 100644 --- a/frontend/src/features/soar/types/soar.types.ts +++ b/frontend/src/features/soar/types/soar.types.ts @@ -1,23 +1,64 @@ /* Mirrors backend modules/soar dto (rule.go, execution.go). */ -export type SoarOperator = 'IS' | 'IS_ONE_OF' | 'IS_NOT_ONE_OF' -export const SOAR_OPERATORS: SoarOperator[] = ['IS', 'IS_ONE_OF', 'IS_NOT_ONE_OF'] +export type SoarOperator = + | 'IS' + | 'IS_NOT' + | 'CONTAINS' + | 'NOT_CONTAINS' + | 'EXISTS' + | 'NOT_EXISTS' + | 'START_WITH' + | 'NOT_START_WITH' + | 'ENDS_WITH' + | 'NOT_ENDS_WITH' + | 'IS_ONE_OF' + | 'IS_NOT_ONE_OF' -/** A flow condition against an alert field. `value` is a string for IS and a - * string[] for IS_ONE_OF / IS_NOT_ONE_OF. */ +export const SOAR_OPERATORS: SoarOperator[] = [ + 'IS', + 'IS_NOT', + 'CONTAINS', + 'NOT_CONTAINS', + 'EXISTS', + 'NOT_EXISTS', + 'START_WITH', + 'NOT_START_WITH', + 'ENDS_WITH', + 'NOT_ENDS_WITH', + 'IS_ONE_OF', + 'IS_NOT_ONE_OF', +] + +export const SOAR_MULTI_VALUE_OPERATORS: SoarOperator[] = ['IS_ONE_OF', 'IS_NOT_ONE_OF'] +export const SOAR_NO_VALUE_OPERATORS: SoarOperator[] = ['EXISTS', 'NOT_EXISTS'] + +/** A flow condition against an alert field. `value` is string[] for + * IS_ONE_OF/IS_NOT_ONE_OF, unused for EXISTS/NOT_EXISTS, and a string + * otherwise. */ export interface FlowCondition { operator: SoarOperator field: string value: unknown } +export type SoarCondition = 'OnSuccess' | 'OnFailure' | 'Always' +export const SOAR_CONDITIONS: SoarCondition[] = ['OnSuccess', 'OnFailure', 'Always'] + +/** Join semantic for a command relative to the previous one: + * OnSuccess → `&&`, OnFailure → `||`, Always → `;`. Absent on the first + * command (nothing to chain against). */ +export interface FlowCommand { + command: string + condition?: SoarCondition +} + /** An alert-response flow (file-backed YAML). Identity is `relPath`. */ export interface Flow { relPath: string name: string description: string conditions: FlowCondition[] - commands: string[] + commands: FlowCommand[] active: boolean agentPlatform: string defaultAgent: string @@ -32,7 +73,7 @@ export interface SaveFlowInput { name: string description: string conditions: FlowCondition[] - commands: string[] + commands: FlowCommand[] active: boolean agentPlatform: string defaultAgent: string @@ -49,6 +90,21 @@ export interface FlowListQuery { size?: number } +/** Reusable command template served from `/soar/action-templates`. Selecting + * one seeds the flow-creation form. */ +export interface ActionTemplate { + id: number + title: string + description?: string + command: string + systemOwner: boolean +} + +export interface ActionTemplateListQuery { + page?: number // 0-based + size?: number +} + export type ExecutionStatus = 'EXECUTED' | 'PENDING' | 'FAILED' export type NonExecutionCause = 'AGENT_OFFLINE' | 'AGENT_NOT_FOUND' | 'UNKNOWN' diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 748ae3cad..7f5e5e569 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -4458,6 +4458,15 @@ "loadError": "Flows konnten nicht geladen werden.", "retry": "Erneut versuchen", "empty": "Keine Flows für diese Ansicht.", + "templates": { + "title": "Neuen Flow starten", + "subtitle": "Wähle eine Aktionsvorlage oder starte bei Null.", + "loading": "Vorlagen werden geladen…", + "loadError": "Vorlagen konnten nicht geladen werden.", + "empty": "Keine Vorlagen verfügbar.", + "cancel": "Abbrechen", + "scratch": "Bei Null anfangen" + }, "tabs": { "flows": "Flows", "executions": "Ausführungsverlauf", @@ -4479,6 +4488,15 @@ }, "operator": { "IS": "ist", + "IS_NOT": "ist nicht", + "CONTAINS": "enthält", + "NOT_CONTAINS": "enthält nicht", + "EXISTS": "existiert", + "NOT_EXISTS": "existiert nicht", + "START_WITH": "beginnt mit", + "NOT_START_WITH": "beginnt nicht mit", + "ENDS_WITH": "endet mit", + "NOT_ENDS_WITH": "endet nicht mit", "IS_ONE_OF": "ist eines von", "IS_NOT_ONE_OF": "ist keines von" }, @@ -4505,6 +4523,11 @@ "commands": "Befehle", "commandsHint": "Werden der Reihe nach auf dem Agenten ausgeführt. $(field.path) für Alert-Werte, $[variables.NAME] für gespeicherte Variablen.", "addCommand": "Befehl hinzufügen", + "connector": { + "always": "immer", + "success": "bei Erfolg", + "failure": "bei Fehler" + }, "agentPlatform": "Agent-Plattform", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 205fc4b57..5ea8dda9e 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -4622,6 +4622,15 @@ "loadError": "Could not load flows.", "retry": "Retry", "empty": "No flows match this view.", + "templates": { + "title": "Start a new flow", + "subtitle": "Pick an action template or start from scratch.", + "loading": "Loading templates…", + "loadError": "Could not load templates.", + "empty": "No templates available.", + "cancel": "Cancel", + "scratch": "Start from scratch" + }, "tabs": { "flows": "Flows", "executions": "Execution history", @@ -4643,6 +4652,15 @@ }, "operator": { "IS": "is", + "IS_NOT": "is not", + "CONTAINS": "contains", + "NOT_CONTAINS": "does not contain", + "EXISTS": "exists", + "NOT_EXISTS": "does not exist", + "START_WITH": "starts with", + "NOT_START_WITH": "does not start with", + "ENDS_WITH": "ends with", + "NOT_ENDS_WITH": "does not end with", "IS_ONE_OF": "is one of", "IS_NOT_ONE_OF": "is not one of" }, @@ -4669,6 +4687,11 @@ "commands": "Commands", "commandsHint": "Run in order on the agent. Use $(field.path) for alert values and $[variables.NAME] for stored variables.", "addCommand": "Add command", + "connector": { + "always": "always", + "success": "on success", + "failure": "on failure" + }, "agentPlatform": "Agent platform", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 60cd068e5..b15efd41f 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -4546,6 +4546,15 @@ "loadError": "No se pudieron cargar los flujos.", "retry": "Reintentar", "empty": "Ningún flujo coincide con esta vista.", + "templates": { + "title": "Nuevo flujo", + "subtitle": "Elige una plantilla de acción o empieza desde cero.", + "loading": "Cargando plantillas…", + "loadError": "No se pudieron cargar las plantillas.", + "empty": "No hay plantillas disponibles.", + "cancel": "Cancelar", + "scratch": "Empezar desde cero" + }, "tabs": { "flows": "Flujos", "executions": "Historial de ejecuciones", @@ -4567,6 +4576,15 @@ }, "operator": { "IS": "es", + "IS_NOT": "no es", + "CONTAINS": "contiene", + "NOT_CONTAINS": "no contiene", + "EXISTS": "existe", + "NOT_EXISTS": "no existe", + "START_WITH": "comienza con", + "NOT_START_WITH": "no comienza con", + "ENDS_WITH": "termina con", + "NOT_ENDS_WITH": "no termina con", "IS_ONE_OF": "es uno de", "IS_NOT_ONE_OF": "no es uno de" }, @@ -4593,6 +4611,11 @@ "commands": "Comandos", "commandsHint": "Se ejecutan en orden en el agente. Usa $(field.path) para valores de la alerta y $[variables.NAME] para variables guardadas.", "addCommand": "Agregar comando", + "connector": { + "always": "siempre", + "success": "si tuvo éxito", + "failure": "si falló" + }, "agentPlatform": "Plataforma del agente", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index fa8296722..422f57e90 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -4458,6 +4458,15 @@ "loadError": "Impossible de charger les flux.", "retry": "Réessayer", "empty": "Aucun flux ne correspond à cette vue.", + "templates": { + "title": "Nouveau flux", + "subtitle": "Choisis un modèle d'action ou pars de zéro.", + "loading": "Chargement des modèles…", + "loadError": "Impossible de charger les modèles.", + "empty": "Aucun modèle disponible.", + "cancel": "Annuler", + "scratch": "Partir de zéro" + }, "tabs": { "flows": "Flux", "executions": "Historique d'exécutions", @@ -4479,6 +4488,15 @@ }, "operator": { "IS": "est", + "IS_NOT": "n'est pas", + "CONTAINS": "contient", + "NOT_CONTAINS": "ne contient pas", + "EXISTS": "existe", + "NOT_EXISTS": "n'existe pas", + "START_WITH": "commence par", + "NOT_START_WITH": "ne commence pas par", + "ENDS_WITH": "se termine par", + "NOT_ENDS_WITH": "ne se termine pas par", "IS_ONE_OF": "est l'un de", "IS_NOT_ONE_OF": "n'est pas l'un de" }, @@ -4505,6 +4523,11 @@ "commands": "Commandes", "commandsHint": "Exécutées dans l'ordre sur l'agent. Utilisez $(field.path) pour les valeurs de l'alerte et $[variables.NAME] pour les variables enregistrées.", "addCommand": "Ajouter une commande", + "connector": { + "always": "toujours", + "success": "en cas de succès", + "failure": "en cas d'échec" + }, "agentPlatform": "Plateforme de l'agent", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 6be5e0ade..2209c0481 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -4458,6 +4458,15 @@ "loadError": "Impossibile caricare i flussi.", "retry": "Riprova", "empty": "Nessun flusso corrisponde a questa vista.", + "templates": { + "title": "Nuovo flusso", + "subtitle": "Scegli un modello di azione o parti da zero.", + "loading": "Caricamento modelli…", + "loadError": "Impossibile caricare i modelli.", + "empty": "Nessun modello disponibile.", + "cancel": "Annulla", + "scratch": "Parti da zero" + }, "tabs": { "flows": "Flussi", "executions": "Cronologia esecuzioni", @@ -4479,6 +4488,15 @@ }, "operator": { "IS": "è", + "IS_NOT": "non è", + "CONTAINS": "contiene", + "NOT_CONTAINS": "non contiene", + "EXISTS": "esiste", + "NOT_EXISTS": "non esiste", + "START_WITH": "inizia con", + "NOT_START_WITH": "non inizia con", + "ENDS_WITH": "termina con", + "NOT_ENDS_WITH": "non termina con", "IS_ONE_OF": "è uno di", "IS_NOT_ONE_OF": "non è uno di" }, @@ -4505,6 +4523,11 @@ "commands": "Comandi", "commandsHint": "Eseguiti in ordine sull'agente. Usa $(field.path) per i valori dell'alert e $[variables.NAME] per le variabili salvate.", "addCommand": "Aggiungi comando", + "connector": { + "always": "sempre", + "success": "se riuscito", + "failure": "se fallito" + }, "agentPlatform": "Piattaforma dell'agente", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index c7b9b26a0..a54532b41 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -4546,6 +4546,15 @@ "loadError": "Não foi possível carregar os fluxos.", "retry": "Tentar de novo", "empty": "Nenhum fluxo corresponde a esta visão.", + "templates": { + "title": "Novo fluxo", + "subtitle": "Escolha um modelo de ação ou comece do zero.", + "loading": "Carregando modelos…", + "loadError": "Não foi possível carregar os modelos.", + "empty": "Nenhum modelo disponível.", + "cancel": "Cancelar", + "scratch": "Começar do zero" + }, "tabs": { "flows": "Fluxos", "executions": "Histórico de execuções", @@ -4567,6 +4576,15 @@ }, "operator": { "IS": "é", + "IS_NOT": "não é", + "CONTAINS": "contém", + "NOT_CONTAINS": "não contém", + "EXISTS": "existe", + "NOT_EXISTS": "não existe", + "START_WITH": "começa com", + "NOT_START_WITH": "não começa com", + "ENDS_WITH": "termina com", + "NOT_ENDS_WITH": "não termina com", "IS_ONE_OF": "é um de", "IS_NOT_ONE_OF": "não é um de" }, @@ -4593,6 +4611,11 @@ "commands": "Comandos", "commandsHint": "Executam em ordem no agente. Use $(field.path) para valores do alerta e $[variables.NAME] para variáveis salvas.", "addCommand": "Adicionar comando", + "connector": { + "always": "sempre", + "success": "em caso de sucesso", + "failure": "em caso de falha" + }, "agentPlatform": "Plataforma do agente", "shell": "Shell", "shellAuto": "auto", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index a3ad16ab5..ec4013459 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -4281,6 +4281,15 @@ "loadError": "Не удалось загрузить потоки.", "retry": "Повторить", "empty": "Нет потоков для этого вида.", + "templates": { + "title": "Новый поток", + "subtitle": "Выберите шаблон действия или начните с нуля.", + "loading": "Загрузка шаблонов…", + "loadError": "Не удалось загрузить шаблоны.", + "empty": "Шаблоны отсутствуют.", + "cancel": "Отмена", + "scratch": "Начать с нуля" + }, "tabs": { "flows": "Потоки", "executions": "История выполнений", @@ -4302,6 +4311,15 @@ }, "operator": { "IS": "равно", + "IS_NOT": "не равно", + "CONTAINS": "содержит", + "NOT_CONTAINS": "не содержит", + "EXISTS": "существует", + "NOT_EXISTS": "не существует", + "START_WITH": "начинается с", + "NOT_START_WITH": "не начинается с", + "ENDS_WITH": "заканчивается на", + "NOT_ENDS_WITH": "не заканчивается на", "IS_ONE_OF": "одно из", "IS_NOT_ONE_OF": "не одно из" }, @@ -4328,6 +4346,11 @@ "commands": "Команды", "commandsHint": "Выполняются по порядку на агенте. Используйте $(field.path) для значений алерта и $[variables.NAME] для сохранённых переменных.", "addCommand": "Добавить команду", + "connector": { + "always": "всегда", + "success": "при успехе", + "failure": "при ошибке" + }, "agentPlatform": "Платформа агента", "shell": "Оболочка", "shellAuto": "авто",