diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go new file mode 100644 index 000000000..6b4fe2976 --- /dev/null +++ b/cmd/soarca-conversion/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "soarca/internal/logger" + "soarca/pkg/conversion" +) + +const banner = ` + _____ ____ _____ _____ + / ____|/ __ \ /\ | __ \ / ____| /\ + | (___ | | | | / \ | |__) | | / \ + \___ \| | | |/ /\ \ | _ /| | / /\ \ + ____) | |__| / ____ \| | \ \| |____ / ____ \ + |_____/ \____/_/ \_\_| \_\\_____/_/ \_\ + + + +` + +var log *logger.Log + +var ( + Version string + Buildtime string + Host string +) + +func init() { + log = logger.Logger("CONVERTER", logger.Info, "", logger.Json) +} + +func print_help() { + fmt.Println("Usage: soarca-conversion -source=SOURCE_FILE [-target=TARGET_FILE] [-format=FORMAT] [-h]") +} + +func main() { + fmt.Print(banner) + log.Info("Version: ", Version) + log.Info("Buildtime: ", Buildtime) + var ( + source_filename string + target_filename string + format string + ) + flag.StringVar(&source_filename, "source", "", "The source file to be converted") + flag.StringVar(&target_filename, "target", "", "The name of the converted filename") + flag.StringVar(&format, "format", "", "The format of the source file") + help := flag.Bool("help", false, "Print usage information") + flag.Parse() + if *help { + print_help() + return + } + if source_filename == "" { + log.Error("No source file given: -source=SOURCE_FILE is required") + print_help() + return + } + if target_filename == "" { + target_filename = fmt.Sprintf("%s.json", source_filename) + log.Infof("No target file given: defaulting to %s", target_filename) + } + source_content, err := os.ReadFile(source_filename) + if err != nil { + log.Errorf("Could not read source file") + return + } + target_content, err := conversion.PerformConversion(source_filename, source_content, format) + if err != nil { + log.Error(err) + return + } + output_str, err := json.Marshal(target_content) + if err != nil { + log.Error(err) + return + } + if err := os.WriteFile(target_filename, output_str, 0644); err != nil { + log.Error(err) + return + } + +} diff --git a/makefile b/makefile index 6d3de330d..425130ace 100644 --- a/makefile +++ b/makefile @@ -1,4 +1,4 @@ -.PHONY: all test integration-test ci-test clean build docker run pre-docker-build swagger sbom +.PHONY: all test integration-test ci-test clean build docker run pre-docker-build swagger sbom build BINARY_NAME=soarca DIRECTORY = $(sort $(dir $(wildcard ./test/*/))) @@ -14,11 +14,12 @@ swagger: swag init -g main.go -o api -d cmd/soarca/,api lint: swagger - golangci-lint run --max-same-issues 0 --timeout 5m -v -build: swagger - CGO_ENABLED=0 go build -o ./build/soarca $(GOFLAGS) ./cmd/soarca/main.go +build: swagger build/soarca build/soarca-conversion + +build/%: $(wildcard **/*.go) + CGO_ENABLED=0 go build -o $@ $(GOFLAGS) ./cmd/$(@F)/main.go test: swagger go test ./pkg/... -v diff --git a/pkg/conversion/bpmn/bpmn_conversion.go b/pkg/conversion/bpmn/bpmn_conversion.go new file mode 100644 index 000000000..6fbcf9319 --- /dev/null +++ b/pkg/conversion/bpmn/bpmn_conversion.go @@ -0,0 +1,391 @@ +package conversion + +import ( + "encoding/xml" + "errors" + "fmt" + "path" + "slices" + "soarca/internal/logger" + "soarca/pkg/models/cacao" + util "soarca/pkg/utils/conversion" + "strings" + + "github.com/google/uuid" +) + +var log *logger.Log + +func init() { + log = logger.Logger("PBMN", logger.Info, "", logger.Json) +} + +type BpmnConverter struct { + translation map[string]string + process *BpmnProcess +} + +/* This structure is somewhat unfortunate; the BPMN process definition is a non-homogeneous list +** of different playbook componenents, including tasks, the arrows/"flows" between tasks, and +** gateways (which become if/else constructs in cacao). +** We could put all of their associated fields and attributes in one process-item type, but this would shift +** the burden of finding out what fields belong to what kind of element to the developer. This is quite error-prone, +** and also would be hard to maintain. +** The current implementation involves this collection of types for different kinds of BPMN process elements, with a +** custom XML decoding step to collect them. + */ +type BpmnStartEvent struct { + Id string `xml:"id,attr"` + Outgoing string `xml:"bpmn:outgoing"` +} +type BpmnEndEvent struct { + Id string `xml:"id,attr"` + Incoming string `xml:"bpmn:incoming"` +} +type BpmnTask struct { + Kind string + Id string `xml:"id,attr"` + Name string `xml:"name,attr"` +} + +type BpmnFlow struct { + Id string `xml:"id,attr"` + SourceRef string `xml:"sourceRef,attr"` + TargetRef string `xml:"targetRef,attr"` + Name string `xml:"name,attr"` + IsAssociation bool +} + +type BpmnGatewayKind int + +const ( + GatewayKindExclusive BpmnGatewayKind = iota + GatewayKindParallel +) + +type BpmnGateway struct { + Id string `xml:"id,attr"` + Name string `xml:"name,attr"` + Kind BpmnGatewayKind +} + +type BpmnAnnotation struct { + Id string `xml:"id,attr"` + Text string `xml:"text"` +} + +func clean_filename(filename string) string { + base := path.Base(filename) + ext := path.Ext(base) + return strings.TrimSuffix(base, ext) +} + +func (converter BpmnConverter) Convert(input []byte, filename string) (*cacao.Playbook, error) { + converter.translation = make(map[string]string) + var definitions BpmnDefinitions + if err := xml.Unmarshal(input, &definitions); err != nil { + return nil, err + } + if len(definitions.Processes) > 1 { + return nil, errors.New("unsupported: BPMN file with multiple processes") + } + if len(definitions.Processes) == 0 { + return nil, errors.New("BPMN file does not have any processes") + } + playbook, soarca_name, soarca_manual_name := util.NewSoarcaPlaybook(clean_filename(filename), []string{"notification"}) + playbook.Description = fmt.Sprintf("CACAO playbook converted from %s", filename) + converter.translation["soarca"] = soarca_name + converter.translation["soarca-manual"] = soarca_manual_name + playbook.TargetDefinitions = cacao.NewAgentTargets( + cacao.AgentTarget{ + ID: fmt.Sprintf("individual--%s", uuid.New()), + Type: "individual", + Name: ""}) + playbook.Workflow = make(cacao.Workflow) + converter.process = &definitions.Processes[0] + if err := converter.implement(definitions.Processes[0], playbook); err != nil { + return nil, err + } + return playbook, nil +} +func NewBpmnConverter() BpmnConverter { + return BpmnConverter{} +} + +type BpmnFile struct { + Definition BpmnDefinitions `xml:"definitions"` +} +type BpmnDefinitions struct { + Processes []BpmnProcess `xml:"process"` +} +type BpmnProcess struct { + start_task *BpmnStartEvent + end_tasks []BpmnEndEvent + flows []BpmnFlow + tasks []BpmnTask + gateways []BpmnGateway + annotations []BpmnAnnotation +} + +func (process *BpmnProcess) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { + start_name := start.Name + for { + item, err := decoder.Token() + if err != nil { + return err + } + switch item_type := item.(type) { + case xml.StartElement: + switch item_type.Name.Local { + case "startEvent": + err = decoder.DecodeElement(&process.start_task, &item_type) + if err != nil { + return err + } + case "endEvent": + end_task := BpmnEndEvent{} + err = decoder.DecodeElement(&end_task, &item_type) + process.end_tasks = append(process.end_tasks, end_task) + if err != nil { + return err + } + case "sequenceFlow": + flow := new(BpmnFlow) + err = decoder.DecodeElement(flow, &item_type) + flow.IsAssociation = false + if err != nil { + return err + } + process.flows = append(process.flows, *flow) + case "association": + flow := new(BpmnFlow) + err = decoder.DecodeElement(flow, &item_type) + flow.IsAssociation = true + if err != nil { + return err + } + process.flows = append(process.flows, *flow) + case "scriptTask": + task := new(BpmnTask) + task.Kind = "script" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "task": + task := new(BpmnTask) + task.Kind = "task" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "serviceTask": + task := new(BpmnTask) + task.Kind = "service" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "sendTask": + task := new(BpmnTask) + task.Kind = "send" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "userTask": + task := new(BpmnTask) + task.Kind = "user" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "businessRuleTask": + task := new(BpmnTask) + task.Kind = "business rule" + err = decoder.DecodeElement(task, &item_type) + if err != nil { + return err + } + process.tasks = append(process.tasks, *task) + case "exclusiveGateway": + gateway := new(BpmnGateway) + err = decoder.DecodeElement(gateway, &item_type) + gateway.Kind = GatewayKindExclusive + if err != nil { + return err + } + process.gateways = append(process.gateways, *gateway) + case "parallelGateway": + gateway := new(BpmnGateway) + err = decoder.DecodeElement(gateway, &item_type) + gateway.Kind = GatewayKindParallel + if err != nil { + return err + } + process.gateways = append(process.gateways, *gateway) + case "textAnnotation": + annotation := new(BpmnAnnotation) + err = decoder.DecodeElement(annotation, &item_type) + if err != nil { + return err + } + process.annotations = append(process.annotations, *annotation) + case "intermediateThrowEvent", "intermediateCatchEvent": + return fmt.Errorf("throw/catch mechanism is currently not implemented in SOARCA") + default: + return fmt.Errorf("unsupported element: %s", item_type.Name.Local) + } + case xml.EndElement: + if item_type.Name == start_name { + return nil + } + } + } +} + +func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.Playbook) error { + log.Info("Implementing start task ", process.start_task.Id) + if err := process.start_task.implement(playbook, converter); err != nil { + return err + } + for _, end := range process.end_tasks { + log.Info("Implementing end ", end.Id) + if err := end.implement(playbook, converter); err != nil { + return err + } + } + log.Infof("Implementing %d tasks, %d gateways, and %d flows", len(process.tasks), len(process.gateways), len(process.flows)) + for _, task := range process.tasks { + log.Info("Implementing task ", task.Name) + if err := task.implement(playbook, converter); err != nil { + return err + } + } + for _, gateway := range process.gateways { + log.Info("Implementing gateway ", gateway.Name) + if err := gateway.implement(playbook, converter); err != nil { + return err + } + } + for _, flow := range process.flows { + log.Info("Implementing flow ", flow.Id) + if err := flow.implement(playbook, converter); err != nil { + return err + } + } + return nil +} + +func (task BpmnTask) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + step_id := fmt.Sprintf("action--%s", uuid.New()) + converter.translation[task.Id] = step_id + step := cacao.Step{Type: "action", Name: task.Name, Commands: []cacao.Command{{Type: "manual", Command: task.Name}}} + step.Agent = converter.translation["soarca"] + playbook.Workflow[step_id] = step + return nil +} +func create_step(type_ string, step_name string, playbook *cacao.Playbook, converter *BpmnConverter) string { + step_id := fmt.Sprintf("%s--%s", type_, uuid.New()) + converter.translation[step_name] = step_id + step := cacao.Step{Type: type_} + playbook.Workflow[step_id] = step + return step_id +} +func (end_event BpmnEndEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + playbook.WorkflowException = create_step("end", end_event.Id, playbook, converter) + return nil +} +func (start_event BpmnStartEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + playbook.WorkflowStart = create_step("start", start_event.Id, playbook, converter) + return nil +} + +func (flow BpmnFlow) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + if flow.IsAssociation { + return flow.implement_association(playbook, converter) + } else { + return flow.implement_flow(playbook, converter) + } +} + +func (flow BpmnFlow) implement_association(playbook *cacao.Playbook, converter *BpmnConverter) error { + source_name, ok := converter.translation[flow.SourceRef] + if !ok { + return fmt.Errorf("could not translate source of flow: %s", flow.SourceRef) + } + source := playbook.Workflow[source_name] + target_index := slices.IndexFunc(converter.process.annotations, func(annot BpmnAnnotation) bool { return annot.Id == flow.TargetRef }) + if target_index < 0 { + return fmt.Errorf("could not find text annotation %s", flow.TargetRef) + } + target := converter.process.annotations[target_index] + source.Condition = target.Text + playbook.Workflow[source_name] = source + return nil +} +func (flow BpmnFlow) implement_flow(playbook *cacao.Playbook, converter *BpmnConverter) error { + source_name, ok := converter.translation[flow.SourceRef] + if !ok { + return fmt.Errorf("could not translate source of flow: %s", flow.SourceRef) + } + target_name, ok := converter.translation[flow.TargetRef] + if !ok { + return fmt.Errorf("could not translate target of flow: %s", flow.TargetRef) + } + log.Infof("Flow from %s(%s) to %s(%s)", source_name, flow.SourceRef, target_name, flow.TargetRef) + entry, ok := playbook.Workflow[source_name] + if !ok { + return fmt.Errorf("could not get source of flow: %s", source_name) + } + switch entry.Type { + case cacao.StepTypeIfCondition: + switch flow.Name { + case "Yes", "yes": + entry.OnTrue = target_name + case "No", "no": + entry.OnFalse = target_name + default: + log.Infof("Unknown flow name %s out of if-condition: picking empty branch", flow.Name) + if entry.OnTrue == "" { + entry.OnTrue = target_name + } else if entry.OnFalse == "" { + entry.OnTrue = target_name + } else { + return fmt.Errorf("branch out of exclusive gateway with more than two branches: not supported") + } + } + case cacao.StepTypeParallel: + entry.NextSteps = append(entry.NextSteps, target_name) + default: + entry.OnCompletion = target_name + + } + playbook.Workflow[source_name] = entry + return nil +} + +func (gateway BpmnGateway) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + switch gateway.Kind { + case GatewayKindExclusive: + return gateway.implement_gateway("if-condition", cacao.StepTypeIfCondition, playbook, converter) + case GatewayKindParallel: + return gateway.implement_gateway("parallel", cacao.StepTypeParallel, playbook, converter) + } + return nil +} +func (gateway BpmnGateway) implement_gateway(step_id_prefix string, condition_type string, playbook *cacao.Playbook, converter *BpmnConverter) error { + condition := cacao.Step{ + Type: condition_type, + Condition: gateway.Name, + } + step_id := fmt.Sprintf("%s--%s", step_id_prefix, uuid.New()) + converter.translation[gateway.Id] = step_id + playbook.Workflow[step_id] = condition + return nil +} diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go new file mode 100644 index 000000000..1c7ff857b --- /dev/null +++ b/pkg/conversion/conversion_test.go @@ -0,0 +1,63 @@ +package conversion + +import ( + "encoding/json" + "os" + model "soarca/pkg/models/conversion" + "soarca/pkg/models/validator" + util "soarca/pkg/utils/conversion" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_read_format(t *testing.T) { + assert.Equal(t, util.ReadFormat("bpmn"), model.FormatBpmn) + assert.Equal(t, util.ReadFormat(""), model.FormatUnknown) + assert.Equal(t, util.ReadFormat("cacao"), model.FormatUnknown) + assert.Equal(t, util.ReadFormat("bpnm"), model.FormatUnknown) + assert.Equal(t, util.ReadFormat("?"), model.FormatUnknown) +} +func Test_guess_format(t *testing.T) { + assert.Equal(t, util.GuessFormat("x.bpmn"), model.FormatBpmn) +} + +func Test_bpmn_format(t *testing.T) { + content, err := os.ReadFile("../../test/conversion/simple_ssh.bpmn") + assert.Equal(t, err, nil) + converted, err := PerformConversion("../../test/conversion/simple_ssh.bpmn", content, "bpmn") + assert.Equal(t, err, nil) + converted_json, err := json.Marshal(converted) + assert.Nil(t, err) + err = validator.IsValidCacaoJson(converted_json) + assert.Equal(t, err, nil) + assert.NotEqual(t, converted, nil) + assert.True(t, strings.HasPrefix(converted.WorkflowStart, "start--")) + assert.True(t, strings.HasPrefix(converted.WorkflowException, "end--")) + assert.NotEqual(t, converted.Workflow, nil) + for _, entry := range converted.Workflow { + assert.NotEqual(t, entry.Name, nil) + assert.NotEqual(t, entry.Type, nil) + } + assert.Equal(t, len(converted.Workflow), 4) +} +func Test_bpmn_format_control(t *testing.T) { + content, err := os.ReadFile("../../test/conversion/control_gates.bpmn") + assert.Equal(t, err, nil) + converted, err := PerformConversion("../../test/conversion/control_gates.bpmn", content, "bpmn") + assert.Equal(t, err, nil) + converted_json, err := json.Marshal(converted) + assert.Equal(t, err, nil) + err = validator.IsValidCacaoJson(converted_json) + assert.Equal(t, err, nil) + assert.NotEqual(t, converted, nil) + assert.True(t, strings.HasPrefix(converted.WorkflowStart, "start--")) + assert.True(t, strings.HasPrefix(converted.WorkflowException, "end--")) + assert.NotEqual(t, converted.Workflow, nil) + for _, entry := range converted.Workflow { + assert.NotEqual(t, entry.Name, nil) + assert.NotEqual(t, entry.Type, nil) + } + assert.Equal(t, len(converted.Workflow), 11) +} diff --git a/pkg/conversion/converter.go b/pkg/conversion/converter.go new file mode 100644 index 000000000..a565be3c3 --- /dev/null +++ b/pkg/conversion/converter.go @@ -0,0 +1,9 @@ +package conversion + +import ( + "soarca/pkg/models/cacao" +) + +type IConverter interface { + Convert(input []byte, filename string) (*cacao.Playbook, error) +} diff --git a/pkg/conversion/perform_conversion.go b/pkg/conversion/perform_conversion.go new file mode 100644 index 000000000..8abdabb3f --- /dev/null +++ b/pkg/conversion/perform_conversion.go @@ -0,0 +1,27 @@ +package conversion + +import ( + "errors" + bpmn_conversion "soarca/pkg/conversion/bpmn" + "soarca/pkg/models/cacao" + model "soarca/pkg/models/conversion" + util "soarca/pkg/utils/conversion" +) + +func PerformConversion(input_filename string, input []byte, format_string string) (*cacao.Playbook, error) { + var format model.TargetFormat + if format_string == "" { + format = util.GuessFormat(input_filename) + } else { + format = util.ReadFormat(format_string) + } + if format == model.FormatUnknown { + return nil, errors.New("could not deduce input file type") + } + var converter IConverter + switch format { + case model.FormatBpmn: + converter = bpmn_conversion.NewBpmnConverter() + } + return converter.Convert(input, input_filename) +} diff --git a/pkg/models/conversion/format.go b/pkg/models/conversion/format.go new file mode 100644 index 000000000..4d025c6c8 --- /dev/null +++ b/pkg/models/conversion/format.go @@ -0,0 +1,8 @@ +package conversion + +type TargetFormat int + +const ( + FormatBpmn TargetFormat = iota + FormatUnknown +) diff --git a/pkg/utils/conversion/conversion.go b/pkg/utils/conversion/conversion.go new file mode 100644 index 000000000..102dab420 --- /dev/null +++ b/pkg/utils/conversion/conversion.go @@ -0,0 +1,20 @@ +package conversion + +import ( + model "soarca/pkg/models/conversion" + "strings" +) + +func GuessFormat(filename string) model.TargetFormat { + if strings.HasSuffix(filename, "bpmn") { + return model.FormatBpmn + } + return model.FormatUnknown +} +func ReadFormat(format string) model.TargetFormat { + switch format { + case "bpmn": + return model.FormatBpmn + } + return model.FormatUnknown +} diff --git a/pkg/utils/conversion/playbook.go b/pkg/utils/conversion/playbook.go new file mode 100644 index 000000000..ef69d30e7 --- /dev/null +++ b/pkg/utils/conversion/playbook.go @@ -0,0 +1,36 @@ +package conversion + +import ( + "fmt" + "soarca/pkg/models/cacao" + "time" + + "github.com/google/uuid" +) + +func NewSoarcaPlaybook(name string, types []string) (*cacao.Playbook, string, string) { + soarca_name := fmt.Sprintf("soarca--%s", uuid.New()) + playbook := cacao.NewPlaybook() + playbook.SpecVersion = cacao.CACAO_VERSION_2 + playbook.Type = "playbook" + playbook.ID = fmt.Sprintf("playbook--%s", uuid.New()) + playbook.CreatedBy = soarca_name + playbook.Name = name + playbook.Created = time.Now().UTC() + playbook.Modified = time.Now().UTC() + playbook.ValidFrom = time.Now().UTC() + playbook.ValidUntil = time.Now().UTC() + playbook.PlaybookTypes = types + soarca_manual_name := fmt.Sprintf("soarca-manual--%s", uuid.New()) + playbook.AgentDefinitions = cacao.NewAgentTargets( + cacao.AgentTarget{ + ID: soarca_name, + Type: "soarca", + Name: "soarca-playbook"}, + cacao.AgentTarget{ + ID: soarca_manual_name, + Type: "soarca-manual", + Name: "soarca-manual", + Description: "SOARCAs manual command handler"}) + return playbook, soarca_name, soarca_manual_name +} diff --git a/test/conversion/cisagov_example.bpmn b/test/conversion/cisagov_example.bpmn new file mode 100644 index 000000000..ec7791792 --- /dev/null +++ b/test/conversion/cisagov_example.bpmn @@ -0,0 +1,331 @@ + + + + + Flow_1oamth0 + + + + Flow_1oamth0 + Flow_14wl6dp + + + + Flow_14wl6dp + Flow_05mgw1o + + + + Flow_05mgw1o + Flow_0r8f2xl + + + Flow_0r8f2xl + Flow_11y152q + Flow_12iuhhn + + + + + Flow_11y152q + Flow_1q7hood + + + + Flow_1q7hood + Flow_1668mg7 + + + + Flow_12iuhhn + Flow_1t1h2rf + + + + Flow_1t1h2rf + Flow_1fg22sg + + + Flow_1fg22sg + Flow_09zwjjm + Flow_15ads41 + + + + + Flow_09zwjjm + Flow_06mq3dp + + + + Flow_15ads41 + Flow_06mq3dp + Flow_1fnodjx + + + + + Flow_1fnodjx + Flow_143yhno + + + + Flow_143yhno + Flow_0rs36uz + + + Flow_0rs36uz + Flow_0am87n3 + Flow_1cg3wth + + + + + Flow_0am87n3 + Flow_1xfzbd2 + + + Flow_1cg3wth + Flow_17l9pwn + Flow_1pkpc2l + + + + + + Flow_17l9pwn + Flow_0cb40pt + + + + Flow_0cb40pt + Flow_158vn0b + + + Flow_1pkpc2l + Flow_0ylofue + + + Flow_1xfzbd2 + Flow_1668mg7 + Flow_0ylofue + Flow_158vn0b + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/conversion/control_gates.bpmn b/test/conversion/control_gates.bpmn new file mode 100755 index 000000000..9075fb6ab --- /dev/null +++ b/test/conversion/control_gates.bpmn @@ -0,0 +1,153 @@ + + + + + Flow_1i1mlqo + + + Flow_1i1mlqo + Flow_1atvcql + + + + Flow_1atvcql + Flow_10p00z2 + Flow_0oolpn4 + + + + Flow_10p00z2 + Flow_1b17xnx + + + + Flow_0oolpn4 + Flow_0j6jgqf + + + + Flow_0j6jgqf + Flow_0ej0p3z + Flow_0wkrtlh + + + + Flow_0ej0p3z + Flow_1lawinb + + + + Flow_0wkrtlh + Flow_1f72x43 + + + + Flow_1lawinb + + + + Flow_1f72x43 + + + + Flow_1b17xnx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/conversion/control_gates.bpmn.json b/test/conversion/control_gates.bpmn.json new file mode 100644 index 000000000..e60acf1f9 --- /dev/null +++ b/test/conversion/control_gates.bpmn.json @@ -0,0 +1 @@ +{"id":"playbook--7ae08520-bd35-46e4-a718-e0f49e756637","type":"playbook","spec_version":"cacao-2.0","name":"control_gates","description":"CACAO playbook converted from test/conversion/control_gates.bpmn","playbook_types":["notification"],"created_by":"soarca-conversion--cb8ba430-20e3-4a03-8319-4b375e48866e","created":"2025-11-11T17:01:18.913324873Z","modified":"2025-11-11T17:01:18.913324926Z","valid_from":"2025-11-11T17:01:18.91332497Z","valid_until":"2025-11-11T17:01:18.913325012Z","workflow_start":"start--8bf0df30-fa78-46ad-8647-dd1f22ec5715","workflow_exception":"end--7a82b86c-a0da-483c-84aa-d9295b717dd5","workflow":{"action--29078f4d-8982-48b1-817d-5bde8766fb7e":{"type":"action","on_completion":"parallel--91d99b65-2903-4e8d-9e4b-a31e0448a0a5","commands":[{"type":"manual","command":"Task C"}],"agent":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332"},"action--a354099d-1658-4d74-9f93-4589dfe2adcd":{"type":"action","on_completion":"if-condition--454ad4cd-45ea-4512-bb5c-c2a1684f14a8","commands":[{"type":"manual","command":"Task A"}],"agent":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332"},"action--b20bf516-ad74-4a46-93d4-e901701e1baa":{"type":"action","on_completion":"end--e2fa7722-6061-4b7b-ba6f-da1b64c5977a","commands":[{"type":"manual","command":"Task D"}],"agent":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332"},"action--c8059f0f-3d14-4f0a-88b9-0368e10acecd":{"type":"action","on_completion":"end--3ed6bd40-4fee-4a12-b094-48fa8f2a17c8","commands":[{"type":"manual","command":"Task E"}],"agent":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332"},"action--df6570cf-99f3-4209-b17b-02f07a898d0f":{"type":"action","on_completion":"end--7a82b86c-a0da-483c-84aa-d9295b717dd5","commands":[{"type":"manual","command":"Task B"}],"agent":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332"},"end--3ed6bd40-4fee-4a12-b094-48fa8f2a17c8":{"type":"end"},"end--7a82b86c-a0da-483c-84aa-d9295b717dd5":{"type":"end"},"end--e2fa7722-6061-4b7b-ba6f-da1b64c5977a":{"type":"end"},"if-condition--454ad4cd-45ea-4512-bb5c-c2a1684f14a8":{"type":"if-condition","condition":"Cond 1","on_true":"action--29078f4d-8982-48b1-817d-5bde8766fb7e","on_false":"action--df6570cf-99f3-4209-b17b-02f07a898d0f"},"parallel--91d99b65-2903-4e8d-9e4b-a31e0448a0a5":{"type":"parallel","next_steps":["action--b20bf516-ad74-4a46-93d4-e901701e1baa","action--c8059f0f-3d14-4f0a-88b9-0368e10acecd"]},"start--8bf0df30-fa78-46ad-8647-dd1f22ec5715":{"type":"start","on_completion":"action--a354099d-1658-4d74-9f93-4589dfe2adcd"}},"agent_definitions":{"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332":{"id":"soarca--0f6fbfdc-61b3-4372-9f12-ab7db64e4332","type":"soarca","name":"soarca-playbook","location":{},"contact":{}},"soarca-manual--07a33364-6560-4e4f-b8fa-1c4a69c5c847":{"id":"soarca-manual--07a33364-6560-4e4f-b8fa-1c4a69c5c847","type":"soarca-manual","name":"soarca-manual","description":"SOARCAs manual command handler","location":{},"contact":{}}},"target_definitions":{"individual--8055ace4-ca2a-4d93-a44f-50126b90b3d6":{"id":"individual--8055ace4-ca2a-4d93-a44f-50126b90b3d6","type":"individual","name":"","location":{},"contact":{}}}} \ No newline at end of file diff --git a/test/conversion/conversion_test.go b/test/conversion/conversion_test.go new file mode 100644 index 000000000..347ca6da4 --- /dev/null +++ b/test/conversion/conversion_test.go @@ -0,0 +1,100 @@ +package conversion + +import ( + "os" + bpmn "soarca/pkg/conversion/bpmn" + "soarca/pkg/models/cacao" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func loadPlaybook(t *testing.T, filename string) *cacao.Playbook { + input, err := os.ReadFile(filename) + assert.Nil(t, err) + playbook, err := bpmn.NewBpmnConverter().Convert(input, filename) + assert.Nil(t, err) + return playbook +} + +func nextSteps(t *testing.T, step cacao.Step, playbook *cacao.Playbook) []cacao.Step { + var steps []cacao.Step + for _, step_name := range step.NextSteps { + steps = append(steps, findStep(t, step_name, playbook)) + } + return steps +} +func findStep(t *testing.T, step_name string, playbook *cacao.Playbook) cacao.Step { + step, ok := playbook.Workflow[step_name] + assert.True(t, ok, "Could not find step", step_name) + return step +} +func findStepByName[S ~[]cacao.Step](t *testing.T, step_name string, steps S) *cacao.Step { + for _, step := range steps { + if step.Name == step_name { + return &step + } + } + assert.Fail(t, "Could not find step", step_name) + return nil +} + +func nextStep(t *testing.T, step cacao.Step, playbook *cacao.Playbook) cacao.Step { + return findStep(t, step.OnCompletion, playbook) +} +func startStep(t *testing.T, playbook *cacao.Playbook) cacao.Step { + return findStep(t, playbook.WorkflowStart, playbook) +} + +func TestControlGatesConversion(t *testing.T) { + playbook := loadPlaybook(t, "control_gates.bpmn") + now := time.Now() + assert.True(t, playbook.Created.Before(now)) + start := startStep(t, playbook) + stepA := nextStep(t, start, playbook) + assert.Equal(t, stepA.Name, "Task A") + exclusive := nextStep(t, stepA, playbook) + assert.True(t, exclusive.Type == cacao.StepTypeIfCondition) + yesCase := findStep(t, exclusive.OnTrue, playbook) + noCase := findStep(t, exclusive.OnFalse, playbook) + assert.Equal(t, noCase.Name, "Task B") + assert.Equal(t, yesCase.Name, "Task C") + next := nextStep(t, noCase, playbook) + assert.True(t, next.Type == cacao.StepTypeEnd) + parallel := nextStep(t, yesCase, playbook) + assert.True(t, parallel.Type == cacao.StepTypeParallel) + nexts := nextSteps(t, parallel, playbook) + stepD := findStepByName(t, "Task D", nexts) + stepE := findStepByName(t, "Task E", nexts) + next = nextStep(t, *stepD, playbook) + assert.True(t, next.Type == cacao.StepTypeEnd) + next = nextStep(t, *stepE, playbook) + assert.True(t, next.Type == cacao.StepTypeEnd) +} + +func TestSimpleSshConversion(t *testing.T) { + playbook := loadPlaybook(t, "simple_ssh.bpmn") + now := time.Now() + assert.True(t, playbook.Created.Before(now)) + start := startStep(t, playbook) + next := nextStep(t, start, playbook) + assert.Equal(t, next.Name, "Execute command") + next = nextStep(t, next, playbook) + assert.Equal(t, next.Name, "Touch file") + next = nextStep(t, next, playbook) + assert.True(t, next.Type == cacao.StepTypeEnd) +} +func TestCisaGovConversion(t *testing.T) { + playbook := loadPlaybook(t, "cisagov_example.bpmn") + now := time.Now() + assert.True(t, playbook.Created.Before(now)) + start := startStep(t, playbook) + next := nextStep(t, start, playbook) + assert.Equal(t, next.Name, "NAC Sends Alert Log to SIEM") + findStep(t, "SOC Investigates Case, Resolves Issue and Closes Ticket", playbook) + findStep(t, "SOAR Adds System MAC to Block List on NAC", playbook) + step := findStep(t, "SOC Contacts System Owner, Resolves Issue and Closes Ticket", playbook) + next = nextStep(t, step, playbook) + assert.True(t, next.Type == cacao.StepTypeEnd) +} diff --git a/test/conversion/simple_ssh.bpmn b/test/conversion/simple_ssh.bpmn new file mode 100755 index 000000000..420ffdde9 --- /dev/null +++ b/test/conversion/simple_ssh.bpmn @@ -0,0 +1,50 @@ + + + + + Flow_1omgzsh + + + + + Flow_0nitd8a + + + + Flow_1omgzsh + Flow_1cwwrrl + + + Flow_1cwwrrl + Flow_0nitd8a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/conversion/simple_ssh.bpmn.json b/test/conversion/simple_ssh.bpmn.json new file mode 100644 index 000000000..0075d8cf9 --- /dev/null +++ b/test/conversion/simple_ssh.bpmn.json @@ -0,0 +1 @@ +{"id":"playbook--39d2bb48-1a04-4e74-afb3-c3258d182f35","type":"playbook","spec_version":"cacao-2.0","name":"simple_ssh","description":"CACAO playbook converted from simple_ssh.bpmn","playbook_types":["notification"],"created_by":"identity--a4fe6ad1-4a17-4ce7-abcb-84ffa83a2d8b","created":"2025-08-29T09:41:36.117586166Z","modified":"2025-08-29T09:41:36.117586228Z","valid_from":"2025-08-29T09:41:36.117586268Z","valid_until":"2025-08-29T09:41:36.117586312Z","workflow_start":"start--de46d026-b31a-44f1-8403-989ad4bb4a3c","workflow_exception":"end--fcbce6a8-c42d-44a8-9791-7e2d45622f78","workflow":{"action--53d2ffe7-ef38-463e-893a-6b8912a88995":{"type":"action","name":"Execute command","on_completion":"action--620ee4a0-a21b-4572-b0fb-2911e5b4a7d3","commands":[{"type":"manual","command":"Execute command"}],"agent":"soarca--281cb2cb-de64-4bcf-880b-21c476ad2b3b"},"action--620ee4a0-a21b-4572-b0fb-2911e5b4a7d3":{"type":"action","name":"Touch file","on_completion":"end--fcbce6a8-c42d-44a8-9791-7e2d45622f78","commands":[{"type":"manual","command":"Touch file"}],"agent":"soarca--281cb2cb-de64-4bcf-880b-21c476ad2b3b"},"end--fcbce6a8-c42d-44a8-9791-7e2d45622f78":{"type":"end"},"start--de46d026-b31a-44f1-8403-989ad4bb4a3c":{"type":"start","on_completion":"action--53d2ffe7-ef38-463e-893a-6b8912a88995"}},"agent_definitions":{"soarca--281cb2cb-de64-4bcf-880b-21c476ad2b3b":{"id":"soarca--281cb2cb-de64-4bcf-880b-21c476ad2b3b","type":"soarca","name":"soarca-playbook","location":{},"contact":{}},"soarca-manual--fd6c5178-c3cf-47a3-9a21-ff676621dbb5":{"id":"soarca-manual--fd6c5178-c3cf-47a3-9a21-ff676621dbb5","type":"soarca-manual","name":"soarca-manual","description":"SOARCAs manual command handler","location":{},"contact":{}}},"target_definitions":{"individual--e75cbf58-9644-407a-9deb-d624cccb746c":{"id":"individual--e75cbf58-9644-407a-9deb-d624cccb746c","type":"individual","name":"CHANGE THIS","location":{},"contact":{}}}} \ No newline at end of file