From b5d7d35c9d5e461310ae25f789c29a211ee9ceb9 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Tue, 5 Aug 2025 17:34:41 +0200 Subject: [PATCH 01/15] Added convertor command and initial work on BPMN convertor --- cmd/soarca-conversion/main.go | 66 ++++++++++++ makefile | 2 +- pkg/conversion/bpmn_conversion.go | 160 ++++++++++++++++++++++++++++ pkg/conversion/conversion.go | 32 ++++++ pkg/conversion/format.go | 33 ++++++ pkg/conversion/misp_conversion.go | 17 +++ pkg/conversion/splunk_conversion.go | 17 +++ 7 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 cmd/soarca-conversion/main.go create mode 100644 pkg/conversion/bpmn_conversion.go create mode 100644 pkg/conversion/conversion.go create mode 100644 pkg/conversion/format.go create mode 100644 pkg/conversion/misp_conversion.go create mode 100644 pkg/conversion/splunk_conversion.go diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go new file mode 100644 index 000000000..da48895f8 --- /dev/null +++ b/cmd/soarca-conversion/main.go @@ -0,0 +1,66 @@ +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 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") + flag.Parse() + if source_filename == "" { + log.Error("No source file given: -source=SOURCE_FILE is required") + return + } + source_content, err := os.ReadFile(source_filename) + if err != nil { + log.Errorf("Could not read source file") + } + target_content, err := conversion.PerformConversion(source_filename, source_content, format) + if err != nil { + log.Error(err) + } + output_str, err := json.Marshal(target_content) + if err != nil { + log.Error(err) + } + os.WriteFile(target_filename, output_str, 0644) + +} diff --git a/makefile b/makefile index 6d3de330d..4de2bdb7d 100644 --- a/makefile +++ b/makefile @@ -14,11 +14,11 @@ 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 + CGO_ENABLED=0 go build -o ./build/soarca-conversion $(GOFLAGS) ./cmd/soarca-conversion/main.go test: swagger go test ./pkg/... -v diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go new file mode 100644 index 000000000..d5c58b6db --- /dev/null +++ b/pkg/conversion/bpmn_conversion.go @@ -0,0 +1,160 @@ +package conversion + +import ( + "encoding/xml" + "errors" + "github.com/google/uuid" + "soarca/pkg/models/cacao" +) + +type BpmnConverter struct { + nodes []IDecodedBpmnNode + tasks map[string]*DecodedBpmnTask + start *DecodedBpmnStartEvent + end *DecodedBpmnEndEvent +} + +func (converter *BpmnConverter) implement(playbook *cacao.Playbook) error { + for _, node := range converter.nodes { + if err := node.Implement(playbook); err != nil { + return err + } + } + return nil +} + +// Basically everything in the playbook; after all is gathered we "Implement" all of these in the cacao playbook +type IDecodedBpmnNode interface { + Implement(*cacao.Playbook) error +} +type DecodedBpmnTask struct { + Kind string + Incoming *IDecodedBpmnNode + Outgoing *IDecodedBpmnNode + Uuid uuid.UUID +} + +func (e DecodedBpmnTask) Implement(*cacao.Playbook) error { + return errors.New("Unimplemented: task") +} + +type DecodedBpmnStartEvent struct { + Outgoing *IDecodedBpmnNode +} + +func (e DecodedBpmnStartEvent) Implement(*cacao.Playbook) error { + return errors.New("Unimplemented: start") +} + +type DecodedBpmnEndEvent struct { + Outgoing *IDecodedBpmnNode +} + +func (e DecodedBpmnEndEvent) Implement(*cacao.Playbook) error { + return errors.New("Unimplemented: end") +} + +func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { + var file BpmnFile + if err := xml.Unmarshal(input, &file); err != nil { + return nil, err + } + if len(file.Processes) > 1 { + return nil, errors.New("Unsupported: BPMN file with multiple processes") + } + if len(file.Processes) == 0 { + return nil, errors.New("BPMN file does not have any processes") + } + converter.gather(file.Processes[0]) + playbook := cacao.NewPlaybook() + converter.implement(playbook) + return nil, errors.New("Unimplemented: further BPMN processing") +} +func NewBpmnConverter() BpmnConverter { + return BpmnConverter{} +} + +type BpmnFile struct { + Processes []BpmnProcess `xml:"bpmn:definitions"` +} +type BpmnProcess struct { + start_task *BpmnStartEvent + end_task *BpmnEndEvent + flows []BpmnFlow + tasks []BpmnTask +} + +func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + item, err := d.Token() + if err != nil { + return err + } + switch item_type := item.(type) { + case xml.StartElement: + switch item_type.Name.Local { + case "bpmn:startEvent": + err = d.DecodeElement(&p.start_task, &item_type) + if err != nil { + return err + } + case "bpmn:endEvent": + err = d.DecodeElement(&p.end_task, &item_type) + if err != nil { + return err + } + case "bpmn:sequenceFlow": + flow := new(BpmnFlow) + err = d.DecodeElement(flow, &item_type) + if err != nil { + return err + } + p.flows = append(p.flows, *flow) + case "bpmn:scriptTask": + task := new(BpmnTask) + task.Kind = "script" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) + } + } + } +} + +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"` + Incoming *string `xml:"bpmn:incoming"` + Outgoing *string `xml:"bpmn:outgoing"` +} + +type BpmnFlow struct { + Id string `xml:"id,attr"` + SourceRef string `xml:"sourceRef,attr"` + TargetRef string `xml:"targetRef,attr"` +} + +func (converter *BpmnConverter) gather(process BpmnProcess) error { + converter.start = &DecodedBpmnStartEvent{} + converter.nodes = append(converter.nodes, converter.start) + converter.end = &DecodedBpmnEndEvent{} + converter.nodes = append(converter.nodes, converter.end) + for _, task := range process.tasks { + converted_task := new(DecodedBpmnTask) + converted_task.Kind = task.Kind + converted_task.Uuid = uuid.New() + converter.tasks[task.Id] = converted_task + converter.nodes = append(converter.nodes, converted_task) + } + return nil +} diff --git a/pkg/conversion/conversion.go b/pkg/conversion/conversion.go new file mode 100644 index 000000000..ddd7b92ba --- /dev/null +++ b/pkg/conversion/conversion.go @@ -0,0 +1,32 @@ +package conversion + +import ( + "errors" + "soarca/pkg/models/cacao" +) + +func PerformConversion(input_filename string, input []byte, format_string string) (*cacao.Playbook, error) { + format := FormatUnknown + if format_string == "" { + format = guess_format(input_filename) + } else { + format = read_format(format_string) + } + if format == FormatUnknown { + return nil, errors.New("Could not deduce input file type") + } + var converter IConverter + switch format { + case FormatBpmn: + converter = NewBpmnConverter() + case FormatMisp: + converter = NewMispConverter() + case FormatSplunk: + converter = NewSplunkConverter() + } + return converter.Convert(input) +} + +type IConverter interface { + Convert(input []byte) (*cacao.Playbook, error) +} diff --git a/pkg/conversion/format.go b/pkg/conversion/format.go new file mode 100644 index 000000000..3a2dd6131 --- /dev/null +++ b/pkg/conversion/format.go @@ -0,0 +1,33 @@ +package conversion + +import "strings" + +type TargetFormat int + +const ( + FormatBpmn TargetFormat = iota + FormatSplunk + FormatMisp + FormatStix + FormatOpenC2 + FormatTaxii + FormatUnknown +) + +func guess_format(filename string) TargetFormat { + if strings.HasSuffix(filename, "bpmn") { + return FormatBpmn + } + return FormatUnknown +} +func read_format(format string) TargetFormat { + switch format { + case "bpmn": + return FormatBpmn + case "misp": + return FormatMisp + case "splunk": + return FormatSplunk + } + return FormatUnknown +} diff --git a/pkg/conversion/misp_conversion.go b/pkg/conversion/misp_conversion.go new file mode 100644 index 000000000..416d63da5 --- /dev/null +++ b/pkg/conversion/misp_conversion.go @@ -0,0 +1,17 @@ +package conversion + +import ( + "errors" + "soarca/pkg/models/cacao" +) + +type MispConverter struct { +} + +func (MispConverter) Convert(input []byte) (*cacao.Playbook, error) { + return nil, errors.New("Unimplemented") + +} +func NewMispConverter() MispConverter { + return MispConverter{} +} diff --git a/pkg/conversion/splunk_conversion.go b/pkg/conversion/splunk_conversion.go new file mode 100644 index 000000000..25a102840 --- /dev/null +++ b/pkg/conversion/splunk_conversion.go @@ -0,0 +1,17 @@ +package conversion + +import ( + "errors" + "soarca/pkg/models/cacao" +) + +type SplunkConverter struct { +} + +func (SplunkConverter) Convert(input []byte) (*cacao.Playbook, error) { + return nil, errors.New("Unimplemented") + +} +func NewSplunkConverter() SplunkConverter { + return SplunkConverter{} +} From 923db9a4d236f73618472882a628f1f889524137 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Tue, 5 Aug 2025 17:35:19 +0200 Subject: [PATCH 02/15] Added format tests and BPMN test file --- pkg/conversion/conversion_test.go | 31 +++++++++++++++++++ test/conversion/simple_ssh.bpmn | 50 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 pkg/conversion/conversion_test.go create mode 100755 test/conversion/simple_ssh.bpmn diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go new file mode 100644 index 000000000..7e51e9d19 --- /dev/null +++ b/pkg/conversion/conversion_test.go @@ -0,0 +1,31 @@ +package conversion + +import ( + "os" + "testing" + + "github.com/go-playground/assert/v2" +) + +func Test_read_format(t *testing.T) { + assert.Equal(t, read_format("bpmn"), FormatBpmn) + assert.Equal(t, read_format("splunk"), FormatSplunk) + assert.Equal(t, read_format("misp"), FormatMisp) + assert.Equal(t, read_format(""), FormatUnknown) + assert.Equal(t, read_format("cacao"), FormatUnknown) + assert.Equal(t, read_format("bpnm"), FormatUnknown) + assert.Equal(t, read_format("?"), FormatUnknown) +} +func Test_guess_format(t *testing.T) { + assert.Equal(t, guess_format("x.bpmn"), FormatBpmn) +} + +func Test_bpmn_format(t *testing.T) { + ssh_simple_file, err := os.ReadFile("../../test/conversion/simple_ssh.bpmn") + assert.NotEqual(t, err, nil) + converted, err := PerformConversion("../../test/conversion/simple_ssh.bpmn", ssh_simple_file, "bpmn") + assert.NotEqual(t, err, nil) + assert.NotEqual(t, converted.WorkflowStart, nil) + assert.NotEqual(t, converted.Workflow, nil) + assert.Equal(t, len(converted.Workflow), 2) +} 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e80e69d61aba7e998a60c0515bebe23b23640ec6 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Wed, 6 Aug 2025 11:46:54 +0200 Subject: [PATCH 03/15] Simplest BPMN file conversion now working --- cmd/soarca-conversion/main.go | 4 + pkg/conversion/bpmn_conversion.go | 176 +++++++++++++++++------------- pkg/conversion/conversion_test.go | 14 ++- 3 files changed, 113 insertions(+), 81 deletions(-) diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go index da48895f8..ed8b982fc 100644 --- a/cmd/soarca-conversion/main.go +++ b/cmd/soarca-conversion/main.go @@ -49,6 +49,10 @@ func main() { log.Error("No source file given: -source=SOURCE_FILE is required") 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") diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index d5c58b6db..0ff82587d 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -3,79 +3,69 @@ package conversion import ( "encoding/xml" "errors" - "github.com/google/uuid" + "fmt" + "soarca/internal/logger" "soarca/pkg/models/cacao" + + "github.com/google/uuid" ) -type BpmnConverter struct { - nodes []IDecodedBpmnNode - tasks map[string]*DecodedBpmnTask - start *DecodedBpmnStartEvent - end *DecodedBpmnEndEvent -} +var log *logger.Log -func (converter *BpmnConverter) implement(playbook *cacao.Playbook) error { - for _, node := range converter.nodes { - if err := node.Implement(playbook); err != nil { - return err - } - } - return nil -} - -// Basically everything in the playbook; after all is gathered we "Implement" all of these in the cacao playbook -type IDecodedBpmnNode interface { - Implement(*cacao.Playbook) error -} -type DecodedBpmnTask struct { - Kind string - Incoming *IDecodedBpmnNode - Outgoing *IDecodedBpmnNode - Uuid uuid.UUID +func init() { + log = logger.Logger("PBMN", logger.Info, "", logger.Json) } -func (e DecodedBpmnTask) Implement(*cacao.Playbook) error { - return errors.New("Unimplemented: task") +type BpmnConverter struct { + translation map[string]string } -type DecodedBpmnStartEvent struct { - Outgoing *IDecodedBpmnNode +type BpmnStartEvent struct { + Id string `xml:"id,attr"` + Outgoing string `xml:"bpmn:outgoing"` } - -func (e DecodedBpmnStartEvent) Implement(*cacao.Playbook) error { - return errors.New("Unimplemented: start") +type BpmnEndEvent struct { + Id string `xml:"id,attr"` + Incoming string `xml:"bpmn:incoming"` } - -type DecodedBpmnEndEvent struct { - Outgoing *IDecodedBpmnNode +type BpmnTask struct { + Kind string + Id string `xml:"id,attr"` + Name string `xml:"name,attr"` } -func (e DecodedBpmnEndEvent) Implement(*cacao.Playbook) error { - return errors.New("Unimplemented: end") +type BpmnFlow struct { + Id string `xml:"id,attr"` + SourceRef string `xml:"sourceRef,attr"` + TargetRef string `xml:"targetRef,attr"` } func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { - var file BpmnFile - if err := xml.Unmarshal(input, &file); err != nil { + converter.translation = make(map[string]string) + var definitions BpmnDefinitions + if err := xml.Unmarshal(input, &definitions); err != nil { return nil, err } - if len(file.Processes) > 1 { + if len(definitions.Processes) > 1 { return nil, errors.New("Unsupported: BPMN file with multiple processes") } - if len(file.Processes) == 0 { + if len(definitions.Processes) == 0 { return nil, errors.New("BPMN file does not have any processes") } - converter.gather(file.Processes[0]) playbook := cacao.NewPlaybook() - converter.implement(playbook) - return nil, errors.New("Unimplemented: further BPMN processing") + playbook.Workflow = make(cacao.Workflow) + converter.implement(definitions.Processes[0], playbook) + return playbook, nil } func NewBpmnConverter() BpmnConverter { return BpmnConverter{} } type BpmnFile struct { - Processes []BpmnProcess `xml:"bpmn:definitions"` + Definition BpmnDefinitions `xml:"definitions"` +} +type BpmnDefinitions struct { + Processes []BpmnProcess `xml:"process"` } type BpmnProcess struct { start_task *BpmnStartEvent @@ -85,6 +75,7 @@ type BpmnProcess struct { } func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + start_name := start.Name for { item, err := d.Token() if err != nil { @@ -93,24 +84,24 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error switch item_type := item.(type) { case xml.StartElement: switch item_type.Name.Local { - case "bpmn:startEvent": + case "startEvent": err = d.DecodeElement(&p.start_task, &item_type) if err != nil { return err } - case "bpmn:endEvent": + case "endEvent": err = d.DecodeElement(&p.end_task, &item_type) if err != nil { return err } - case "bpmn:sequenceFlow": + case "sequenceFlow": flow := new(BpmnFlow) err = d.DecodeElement(flow, &item_type) if err != nil { return err } p.flows = append(p.flows, *flow) - case "bpmn:scriptTask": + case "scriptTask": task := new(BpmnTask) task.Kind = "script" err = d.DecodeElement(task, &item_type) @@ -119,42 +110,73 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error } p.tasks = append(p.tasks, *task) } + case xml.EndElement: + if item_type.Name == start_name { + return nil + } } } } -type BpmnStartEvent struct { - Id string `xml:"id,attr"` - Outgoing string `xml:"bpmn:outgoing"` +func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.Playbook) error { + log.Info("Implementing start task ", process.start_task.Id) + process.start_task.implement(playbook, converter) + log.Info("Implementing end task ", process.end_task.Id) + process.end_task.implement(playbook, converter) + for _, task := range process.tasks { + log.Info("Implementing task ", task.Name) + if err := task.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 } -type BpmnEndEvent struct { - Id string `xml:"id,attr"` - Incoming string `xml:"bpmn:incoming"` + +func (task BpmnTask) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + name := fmt.Sprintf("action--%s", uuid.New()) + converter.translation[task.Id] = name + step := cacao.Step{Type: "action", Name: task.Name} + playbook.Workflow[name] = step + return nil } -type BpmnTask struct { - Kind string - Id string `xml:"id,attr"` - Incoming *string `xml:"bpmn:incoming"` - Outgoing *string `xml:"bpmn:outgoing"` +func (flow BpmnFlow) implement(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) + } + entry.OnCompletion = target_name + playbook.Workflow[source_name] = entry + return nil } -type BpmnFlow struct { - Id string `xml:"id,attr"` - SourceRef string `xml:"sourceRef,attr"` - TargetRef string `xml:"targetRef,attr"` +func (end_event BpmnEndEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + name := fmt.Sprintf("end--%s", uuid.New()) + converter.translation[end_event.Id] = name + step := cacao.Step{Type: "end"} + playbook.Workflow[name] = step + playbook.WorkflowException = name + return nil } - -func (converter *BpmnConverter) gather(process BpmnProcess) error { - converter.start = &DecodedBpmnStartEvent{} - converter.nodes = append(converter.nodes, converter.start) - converter.end = &DecodedBpmnEndEvent{} - converter.nodes = append(converter.nodes, converter.end) - for _, task := range process.tasks { - converted_task := new(DecodedBpmnTask) - converted_task.Kind = task.Kind - converted_task.Uuid = uuid.New() - converter.tasks[task.Id] = converted_task - converter.nodes = append(converter.nodes, converted_task) - } +func (start_event BpmnStartEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + name := fmt.Sprintf("start--%s", uuid.New()) + converter.translation[start_event.Id] = name + step := cacao.Step{Type: "start"} + playbook.Workflow[name] = step + playbook.WorkflowStart = name return nil } diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index 7e51e9d19..67dfc6577 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -22,10 +22,16 @@ func Test_guess_format(t *testing.T) { func Test_bpmn_format(t *testing.T) { ssh_simple_file, err := os.ReadFile("../../test/conversion/simple_ssh.bpmn") - assert.NotEqual(t, err, nil) + assert.Equal(t, err, nil) converted, err := PerformConversion("../../test/conversion/simple_ssh.bpmn", ssh_simple_file, "bpmn") - assert.NotEqual(t, err, nil) - assert.NotEqual(t, converted.WorkflowStart, nil) + assert.Equal(t, err, nil) + assert.NotEqual(t, converted, nil) + assert.MatchRegex(t, converted.WorkflowStart, "start--.*") + assert.MatchRegex(t, converted.WorkflowException, "end--.*") assert.NotEqual(t, converted.Workflow, nil) - assert.Equal(t, len(converted.Workflow), 2) + for _, entry := range converted.Workflow { + assert.NotEqual(t, entry.Name, nil) + assert.NotEqual(t, entry.Type, nil) + } + assert.Equal(t, len(converted.Workflow), 4) } From d33322f297946eb012c39de76427c3b318641bf0 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Wed, 6 Aug 2025 15:30:14 +0200 Subject: [PATCH 04/15] Added control gate support to BPMN conversion --- pkg/conversion/bpmn_conversion.go | 126 ++++++++++++++++++++++-- pkg/conversion/conversion_test.go | 15 +++ test/conversion/control_gates.bpmn | 153 +++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 6 deletions(-) create mode 100755 test/conversion/control_gates.bpmn diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index 0ff82587d..11509918e 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -38,6 +38,19 @@ type BpmnFlow struct { Id string `xml:"id,attr"` SourceRef string `xml:"sourceRef,attr"` TargetRef string `xml:"targetRef,attr"` + Name string `xml:"name,attr"` +} +type BpmnGatewayKind int + +const ( + GatewayKindExclusive BpmnGatewayKind = iota + GatewayKindParallel +) + +type BpmnGateway struct { + Id string `xml:"id,attr"` + Name string `xml:"name,attr"` + Kind BpmnGatewayKind } func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { @@ -53,8 +66,25 @@ func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { return nil, errors.New("BPMN file does not have any processes") } playbook := cacao.NewPlaybook() + playbook.AgentDefinitions = cacao.NewAgentTargets( + cacao.AgentTarget{ + ID: fmt.Sprintf("soarca--%s", uuid.New()), + Type: "soarca", + Name: "soarca-playbook"}, + cacao.AgentTarget{ + ID: fmt.Sprintf("soarca-manual-capability--%s", uuid.New()), + Type: "soarca-manual", + Name: "soarca-manual", + Description: "SOARCAs manual command handler"}) + playbook.TargetDefinitions = cacao.NewAgentTargets( + cacao.AgentTarget{ + ID: fmt.Sprintf("individual--%s", uuid.New()), + Type: "individual", + Name: "CHANGE THIS"}) playbook.Workflow = make(cacao.Workflow) - converter.implement(definitions.Processes[0], playbook) + if err := converter.implement(definitions.Processes[0], playbook); err != nil { + return nil, err + } return playbook, nil } func NewBpmnConverter() BpmnConverter { @@ -69,9 +99,10 @@ type BpmnDefinitions struct { } type BpmnProcess struct { start_task *BpmnStartEvent - end_task *BpmnEndEvent + end_tasks []BpmnEndEvent flows []BpmnFlow tasks []BpmnTask + gateways []BpmnGateway } func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -90,7 +121,9 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return err } case "endEvent": - err = d.DecodeElement(&p.end_task, &item_type) + end_task := BpmnEndEvent{} + err = d.DecodeElement(&end_task, &item_type) + p.end_tasks = append(p.end_tasks, end_task) if err != nil { return err } @@ -109,6 +142,32 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return err } p.tasks = append(p.tasks, *task) + case "task": + task := new(BpmnTask) + task.Kind = "task" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) + case "exclusiveGateway": + gateway := new(BpmnGateway) + err = d.DecodeElement(gateway, &item_type) + gateway.Kind = GatewayKindExclusive + if err != nil { + return err + } + p.gateways = append(p.gateways, *gateway) + case "parallelGateway": + gateway := new(BpmnGateway) + err = d.DecodeElement(gateway, &item_type) + gateway.Kind = GatewayKindParallel + if err != nil { + return err + } + p.gateways = append(p.gateways, *gateway) + default: + return fmt.Errorf("Unsupported element: %s", item_type.Name.Local) } case xml.EndElement: if item_type.Name == start_name { @@ -121,14 +180,25 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.Playbook) error { log.Info("Implementing start task ", process.start_task.Id) process.start_task.implement(playbook, converter) - log.Info("Implementing end task ", process.end_task.Id) - process.end_task.implement(playbook, converter) + 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 { @@ -159,7 +229,22 @@ func (flow BpmnFlow) implement(playbook *cacao.Playbook, converter *BpmnConverte if !ok { return fmt.Errorf("Could not get source of flow: %s", source_name) } - entry.OnCompletion = target_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: + return fmt.Errorf("Unknown if direction: %s", flow.Name) + } + case cacao.StepTypeParallel: + entry.NextSteps = append(entry.NextSteps, target_name) + default: + entry.OnCompletion = target_name + + } playbook.Workflow[source_name] = entry return nil } @@ -180,3 +265,32 @@ func (start_event BpmnStartEvent) implement(playbook *cacao.Playbook, converter playbook.WorkflowStart = name return nil } +func (gateway BpmnGateway) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { + switch gateway.Kind { + case GatewayKindExclusive: + return gateway.implement_exclusive(playbook, converter) + case GatewayKindParallel: + return gateway.implement_parallel(playbook, converter) + } + return nil +} +func (gateway BpmnGateway) implement_exclusive(playbook *cacao.Playbook, converter *BpmnConverter) error { + condition := cacao.Step{ + Type: cacao.StepTypeIfCondition, + Condition: gateway.Name, + } + name := fmt.Sprintf("if-condition--%s", uuid.New()) + converter.translation[gateway.Id] = name + playbook.Workflow[name] = condition + return nil +} +func (gateway BpmnGateway) implement_parallel(playbook *cacao.Playbook, converter *BpmnConverter) error { + condition := cacao.Step{ + Type: cacao.StepTypeParallel, + Condition: gateway.Name, + } + name := fmt.Sprintf("parallel--%s", uuid.New()) + converter.translation[gateway.Id] = name + playbook.Workflow[name] = condition + return nil +} diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index 67dfc6577..8bc73f9a3 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -35,3 +35,18 @@ func Test_bpmn_format(t *testing.T) { } assert.Equal(t, len(converted.Workflow), 4) } +func Test_bpmn_format_control(t *testing.T) { + ssh_simple_file, err := os.ReadFile("../../test/conversion/control_gates.bpmn") + assert.Equal(t, err, nil) + converted, err := PerformConversion("../../test/conversion/control_gates.bpmn", ssh_simple_file, "bpmn") + assert.Equal(t, err, nil) + assert.NotEqual(t, converted, nil) + assert.MatchRegex(t, converted.WorkflowStart, "start--.*") + assert.MatchRegex(t, 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/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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 47c2f63ce3ae873c1c0fdce6d5237b61eda07260 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Wed, 6 Aug 2025 16:15:39 +0200 Subject: [PATCH 05/15] Added required fields to converted files to conform to cacao validator --- pkg/conversion/bpmn_conversion.go | 16 +++++++++++++--- pkg/conversion/conversion_test.go | 12 ++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index 11509918e..3fe9f3f9f 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -66,13 +66,21 @@ func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { return nil, errors.New("BPMN file does not have any processes") } playbook := cacao.NewPlaybook() + playbook.SpecVersion = cacao.CACAO_VERSION_2 + playbook.Type = "playbook" + playbook.ID = fmt.Sprintf("playbook--%s", uuid.New()) + playbook.CreatedBy = fmt.Sprintf("identity--%s", uuid.New()) + soarca_name := fmt.Sprintf("soarca--%s", uuid.New()) + soarca_manual_name := fmt.Sprintf("soarca--%s", uuid.New()) + converter.translation["soarca"] = soarca_name + converter.translation["soarca-manual"] = soarca_manual_name playbook.AgentDefinitions = cacao.NewAgentTargets( cacao.AgentTarget{ - ID: fmt.Sprintf("soarca--%s", uuid.New()), + ID: soarca_name, Type: "soarca", Name: "soarca-playbook"}, cacao.AgentTarget{ - ID: fmt.Sprintf("soarca-manual-capability--%s", uuid.New()), + ID: soarca_manual_name, Type: "soarca-manual", Name: "soarca-manual", Description: "SOARCAs manual command handler"}) @@ -211,7 +219,9 @@ func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.P func (task BpmnTask) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { name := fmt.Sprintf("action--%s", uuid.New()) converter.translation[task.Id] = name - step := cacao.Step{Type: "action", Name: task.Name} + step := cacao.Step{Type: "action", Name: task.Name, Commands: make([]cacao.Command, 0)} + step.Commands = append(step.Commands, cacao.Command{Type: "shell", Command: ""}) + step.Agent = converter.translation["soarca"] playbook.Workflow[name] = step return nil } diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index 8bc73f9a3..dd96bde47 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -2,6 +2,7 @@ package conversion import ( "os" + "soarca/pkg/models/validator" "testing" "github.com/go-playground/assert/v2" @@ -21,9 +22,10 @@ func Test_guess_format(t *testing.T) { } func Test_bpmn_format(t *testing.T) { - ssh_simple_file, err := os.ReadFile("../../test/conversion/simple_ssh.bpmn") + content, err := os.ReadFile("../../test/conversion/simple_ssh.bpmn") assert.Equal(t, err, nil) - converted, err := PerformConversion("../../test/conversion/simple_ssh.bpmn", ssh_simple_file, "bpmn") + converted, err := PerformConversion("../../test/conversion/simple_ssh.bpmn", content, "bpmn") + err = validator.IsValidCacaoJson(content) assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) assert.MatchRegex(t, converted.WorkflowStart, "start--.*") @@ -36,9 +38,11 @@ func Test_bpmn_format(t *testing.T) { assert.Equal(t, len(converted.Workflow), 4) } func Test_bpmn_format_control(t *testing.T) { - ssh_simple_file, err := os.ReadFile("../../test/conversion/control_gates.bpmn") + content, err := os.ReadFile("../../test/conversion/control_gates.bpmn") assert.Equal(t, err, nil) - converted, err := PerformConversion("../../test/conversion/control_gates.bpmn", ssh_simple_file, "bpmn") + err = validator.IsValidCacaoJson(content) + assert.Equal(t, err, nil) + converted, err := PerformConversion("../../test/conversion/control_gates.bpmn", content, "bpmn") assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) assert.MatchRegex(t, converted.WorkflowStart, "start--.*") From a1a8328c1949a2ac2c3208c8cf77f15b44e0b513 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Thu, 7 Aug 2025 16:48:23 +0200 Subject: [PATCH 06/15] Updated to process all compatible CISA examples These are examples of BPMN playbooks. The only unsupported elements in there are: - "intermediateThrowEvent", "intermediateCatchEvent": these require storage/communication between playbooks. We can make a playbook step in cacao, but that's not the same thing (and requires context). - "inclusiveGateway": usually used for a choice, e.g. "what is the course of action". Can be converted using manual steps, but the examples lack text in the gateway to give enough data to do this. The choice for both of these for now is to error and not generate output. There should be more diagnostic output in general on what needs to be done for a meaningful conversion. --- cmd/soarca-conversion/main.go | 3 + pkg/conversion/bpmn_conversion.go | 113 +++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go index ed8b982fc..44e7fd997 100644 --- a/cmd/soarca-conversion/main.go +++ b/cmd/soarca-conversion/main.go @@ -56,14 +56,17 @@ func main() { 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 } os.WriteFile(target_filename, output_str, 0644) diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index 3fe9f3f9f..20f806501 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -4,6 +4,7 @@ import ( "encoding/xml" "errors" "fmt" + "slices" "soarca/internal/logger" "soarca/pkg/models/cacao" @@ -18,6 +19,7 @@ func init() { type BpmnConverter struct { translation map[string]string + process *BpmnProcess } type BpmnStartEvent struct { @@ -35,10 +37,11 @@ type BpmnTask struct { } type BpmnFlow struct { - Id string `xml:"id,attr"` - SourceRef string `xml:"sourceRef,attr"` - TargetRef string `xml:"targetRef,attr"` - Name string `xml:"name,attr"` + 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 @@ -53,6 +56,11 @@ type BpmnGateway struct { Kind BpmnGatewayKind } +type BpmnAnnotation struct { + Id string `xml:"id,attr"` + Text string `xml:"text"` +} + func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { converter.translation = make(map[string]string) var definitions BpmnDefinitions @@ -90,6 +98,7 @@ func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { Type: "individual", Name: "CHANGE THIS"}) playbook.Workflow = make(cacao.Workflow) + converter.process = &definitions.Processes[0] if err := converter.implement(definitions.Processes[0], playbook); err != nil { return nil, err } @@ -106,11 +115,12 @@ type BpmnDefinitions struct { Processes []BpmnProcess `xml:"process"` } type BpmnProcess struct { - start_task *BpmnStartEvent - end_tasks []BpmnEndEvent - flows []BpmnFlow - tasks []BpmnTask - gateways []BpmnGateway + start_task *BpmnStartEvent + end_tasks []BpmnEndEvent + flows []BpmnFlow + tasks []BpmnTask + gateways []BpmnGateway + annotations []BpmnAnnotation } func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -138,6 +148,15 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error case "sequenceFlow": flow := new(BpmnFlow) err = d.DecodeElement(flow, &item_type) + flow.IsAssociation = false + if err != nil { + return err + } + p.flows = append(p.flows, *flow) + case "association": + flow := new(BpmnFlow) + err = d.DecodeElement(flow, &item_type) + flow.IsAssociation = true if err != nil { return err } @@ -158,6 +177,38 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return err } p.tasks = append(p.tasks, *task) + case "serviceTask": + task := new(BpmnTask) + task.Kind = "service" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) + case "sendTask": + task := new(BpmnTask) + task.Kind = "send" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) + case "userTask": + task := new(BpmnTask) + task.Kind = "user" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) + case "businessRuleTask": + task := new(BpmnTask) + task.Kind = "business rule" + err = d.DecodeElement(task, &item_type) + if err != nil { + return err + } + p.tasks = append(p.tasks, *task) case "exclusiveGateway": gateway := new(BpmnGateway) err = d.DecodeElement(gateway, &item_type) @@ -174,6 +225,15 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return err } p.gateways = append(p.gateways, *gateway) + case "textAnnotation": + annotation := new(BpmnAnnotation) + err = d.DecodeElement(annotation, &item_type) + if err != nil { + return err + } + p.annotations = append(p.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) } @@ -220,12 +280,36 @@ func (task BpmnTask) implement(playbook *cacao.Playbook, converter *BpmnConverte name := fmt.Sprintf("action--%s", uuid.New()) converter.translation[task.Id] = name step := cacao.Step{Type: "action", Name: task.Name, Commands: make([]cacao.Command, 0)} - step.Commands = append(step.Commands, cacao.Command{Type: "shell", Command: ""}) + step.Commands = append(step.Commands, cacao.Command{Type: "manual", Command: task.Name}) step.Agent = converter.translation["soarca"] playbook.Workflow[name] = step 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) @@ -247,7 +331,14 @@ func (flow BpmnFlow) implement(playbook *cacao.Playbook, converter *BpmnConverte case "No", "no": entry.OnFalse = target_name default: - return fmt.Errorf("Unknown if direction: %s", flow.Name) + 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) From ecd9a22cf3e71d0a6fc63bef998413fa336ee202 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 8 Aug 2025 15:30:02 +0200 Subject: [PATCH 07/15] Updated tests to check for valid JSON output --- pkg/conversion/conversion_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index dd96bde47..b65a017c0 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -1,6 +1,7 @@ package conversion import ( + "encoding/json" "os" "soarca/pkg/models/validator" "testing" @@ -25,7 +26,9 @@ 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") - err = validator.IsValidCacaoJson(content) + assert.Equal(t, err, nil) + converted_json, err := json.Marshal(converted) + err = validator.IsValidCacaoJson(converted_json) assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) assert.MatchRegex(t, converted.WorkflowStart, "start--.*") @@ -40,10 +43,11 @@ func Test_bpmn_format(t *testing.T) { func Test_bpmn_format_control(t *testing.T) { content, err := os.ReadFile("../../test/conversion/control_gates.bpmn") assert.Equal(t, err, nil) - err = validator.IsValidCacaoJson(content) - 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) + err = validator.IsValidCacaoJson(converted_json) + assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) assert.MatchRegex(t, converted.WorkflowStart, "start--.*") assert.MatchRegex(t, converted.WorkflowException, "end--.*") From 1484dccb8382cd97163bff7e282ea8bf12113213 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Mon, 11 Aug 2025 11:00:32 +0200 Subject: [PATCH 08/15] Added more metadata to converted file This metadata should still be changed by the implementer, as this is not taken from the BPMN playbook. This also does not manage to please Cymph, which does not find this a valid playbook, but refuses to explain why. --- pkg/conversion/bpmn_conversion.go | 20 ++++++++++++++++++-- pkg/conversion/conversion.go | 4 ++-- pkg/conversion/misp_conversion.go | 2 +- pkg/conversion/splunk_conversion.go | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index 20f806501..46038da0b 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -4,9 +4,12 @@ import ( "encoding/xml" "errors" "fmt" + "path" "slices" "soarca/internal/logger" "soarca/pkg/models/cacao" + "strings" + "time" "github.com/google/uuid" ) @@ -61,7 +64,13 @@ type BpmnAnnotation struct { Text string `xml:"text"` } -func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { +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 { @@ -78,8 +87,15 @@ func (converter BpmnConverter) Convert(input []byte) (*cacao.Playbook, error) { playbook.Type = "playbook" playbook.ID = fmt.Sprintf("playbook--%s", uuid.New()) playbook.CreatedBy = fmt.Sprintf("identity--%s", uuid.New()) + playbook.Name = clean_filename(filename) + playbook.Created = time.Now().UTC() + playbook.Modified = time.Now().UTC() + playbook.ValidFrom = time.Now().UTC() + playbook.ValidUntil = time.Now().UTC() + playbook.Description = fmt.Sprintf("CACAO playbook converted from %s", filename) soarca_name := fmt.Sprintf("soarca--%s", uuid.New()) - soarca_manual_name := fmt.Sprintf("soarca--%s", uuid.New()) + playbook.PlaybookTypes = []string{"notification"} + soarca_manual_name := fmt.Sprintf("soarca-manual--%s", uuid.New()) converter.translation["soarca"] = soarca_name converter.translation["soarca-manual"] = soarca_manual_name playbook.AgentDefinitions = cacao.NewAgentTargets( diff --git a/pkg/conversion/conversion.go b/pkg/conversion/conversion.go index ddd7b92ba..3ce0bfcf2 100644 --- a/pkg/conversion/conversion.go +++ b/pkg/conversion/conversion.go @@ -24,9 +24,9 @@ func PerformConversion(input_filename string, input []byte, format_string string case FormatSplunk: converter = NewSplunkConverter() } - return converter.Convert(input) + return converter.Convert(input, input_filename) } type IConverter interface { - Convert(input []byte) (*cacao.Playbook, error) + Convert(input []byte, filename string) (*cacao.Playbook, error) } diff --git a/pkg/conversion/misp_conversion.go b/pkg/conversion/misp_conversion.go index 416d63da5..81a217c72 100644 --- a/pkg/conversion/misp_conversion.go +++ b/pkg/conversion/misp_conversion.go @@ -8,7 +8,7 @@ import ( type MispConverter struct { } -func (MispConverter) Convert(input []byte) (*cacao.Playbook, error) { +func (MispConverter) Convert(input []byte, filename string) (*cacao.Playbook, error) { return nil, errors.New("Unimplemented") } diff --git a/pkg/conversion/splunk_conversion.go b/pkg/conversion/splunk_conversion.go index 25a102840..482964cfe 100644 --- a/pkg/conversion/splunk_conversion.go +++ b/pkg/conversion/splunk_conversion.go @@ -8,7 +8,7 @@ import ( type SplunkConverter struct { } -func (SplunkConverter) Convert(input []byte) (*cacao.Playbook, error) { +func (SplunkConverter) Convert(input []byte, filename string) (*cacao.Playbook, error) { return nil, errors.New("Unimplemented") } From 5e53add6414052dc9aaf30f05cc14249531bc0ff Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Wed, 13 Aug 2025 16:30:34 +0200 Subject: [PATCH 09/15] Fixed linter errors --- cmd/soarca-conversion/main.go | 5 ++++- pkg/conversion/bpmn_conversion.go | 18 +++++++++--------- pkg/conversion/conversion.go | 4 ++-- pkg/conversion/conversion_test.go | 3 ++- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go index 44e7fd997..d30b5b63c 100644 --- a/cmd/soarca-conversion/main.go +++ b/cmd/soarca-conversion/main.go @@ -68,6 +68,9 @@ func main() { log.Error(err) return } - os.WriteFile(target_filename, output_str, 0644) + if err := os.WriteFile(target_filename, output_str, 0644); err != nil { + log.Error(err) + return + } } diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index 46038da0b..ee562a106 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -77,7 +77,7 @@ func (converter BpmnConverter) Convert(input []byte, filename string) (*cacao.Pl return nil, err } if len(definitions.Processes) > 1 { - return nil, errors.New("Unsupported: BPMN file with multiple processes") + 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") @@ -249,9 +249,9 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error } p.annotations = append(p.annotations, *annotation) case "intermediateThrowEvent", "intermediateCatchEvent": - return fmt.Errorf("Throw/catch mechanism is currently not implemented in SOARCA") + return fmt.Errorf("throw/catch mechanism is currently not implemented in SOARCA") default: - return fmt.Errorf("Unsupported element: %s", item_type.Name.Local) + return fmt.Errorf("unsupported element: %s", item_type.Name.Local) } case xml.EndElement: if item_type.Name == start_name { @@ -313,12 +313,12 @@ func (flow BpmnFlow) implement(playbook *cacao.Playbook, converter *BpmnConverte 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) + 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) + return fmt.Errorf("could not find text annotation %s", flow.TargetRef) } target := converter.process.annotations[target_index] source.Condition = target.Text @@ -328,16 +328,16 @@ func (flow BpmnFlow) implement_association(playbook *cacao.Playbook, converter * 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) + 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) + 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) + return fmt.Errorf("could not get source of flow: %s", source_name) } switch entry.Type { case cacao.StepTypeIfCondition: @@ -353,7 +353,7 @@ func (flow BpmnFlow) implement_flow(playbook *cacao.Playbook, converter *BpmnCon } else if entry.OnFalse == "" { entry.OnTrue = target_name } else { - return fmt.Errorf("Branch out of exclusive gateway with more than two branches: not supported") + return fmt.Errorf("branch out of exclusive gateway with more than two branches: not supported") } } case cacao.StepTypeParallel: diff --git a/pkg/conversion/conversion.go b/pkg/conversion/conversion.go index 3ce0bfcf2..17f839cda 100644 --- a/pkg/conversion/conversion.go +++ b/pkg/conversion/conversion.go @@ -6,14 +6,14 @@ import ( ) func PerformConversion(input_filename string, input []byte, format_string string) (*cacao.Playbook, error) { - format := FormatUnknown + var format TargetFormat if format_string == "" { format = guess_format(input_filename) } else { format = read_format(format_string) } if format == FormatUnknown { - return nil, errors.New("Could not deduce input file type") + return nil, errors.New("could not deduce input file type") } var converter IConverter switch format { diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index b65a017c0..045565b66 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -6,7 +6,7 @@ import ( "soarca/pkg/models/validator" "testing" - "github.com/go-playground/assert/v2" + "github.com/stretchr/testify/assert" ) func Test_read_format(t *testing.T) { @@ -28,6 +28,7 @@ func Test_bpmn_format(t *testing.T) { 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) From 6b1b3539f175c4576664bec0b0ec2e5fdddec759 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 29 Aug 2025 13:19:19 +0200 Subject: [PATCH 10/15] Removed unused formats --- pkg/conversion/conversion.go | 4 ---- pkg/conversion/conversion_test.go | 11 +++++------ pkg/conversion/format.go | 9 --------- pkg/conversion/misp_conversion.go | 17 ----------------- pkg/conversion/splunk_conversion.go | 17 ----------------- 5 files changed, 5 insertions(+), 53 deletions(-) delete mode 100644 pkg/conversion/misp_conversion.go delete mode 100644 pkg/conversion/splunk_conversion.go diff --git a/pkg/conversion/conversion.go b/pkg/conversion/conversion.go index 17f839cda..e75f29483 100644 --- a/pkg/conversion/conversion.go +++ b/pkg/conversion/conversion.go @@ -19,10 +19,6 @@ func PerformConversion(input_filename string, input []byte, format_string string switch format { case FormatBpmn: converter = NewBpmnConverter() - case FormatMisp: - converter = NewMispConverter() - case FormatSplunk: - converter = NewSplunkConverter() } return converter.Convert(input, input_filename) } diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index 045565b66..1412aa34c 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "soarca/pkg/models/validator" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,8 +12,6 @@ import ( func Test_read_format(t *testing.T) { assert.Equal(t, read_format("bpmn"), FormatBpmn) - assert.Equal(t, read_format("splunk"), FormatSplunk) - assert.Equal(t, read_format("misp"), FormatMisp) assert.Equal(t, read_format(""), FormatUnknown) assert.Equal(t, read_format("cacao"), FormatUnknown) assert.Equal(t, read_format("bpnm"), FormatUnknown) @@ -32,8 +31,8 @@ func Test_bpmn_format(t *testing.T) { err = validator.IsValidCacaoJson(converted_json) assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) - assert.MatchRegex(t, converted.WorkflowStart, "start--.*") - assert.MatchRegex(t, converted.WorkflowException, "end--.*") + 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) @@ -50,8 +49,8 @@ func Test_bpmn_format_control(t *testing.T) { err = validator.IsValidCacaoJson(converted_json) assert.Equal(t, err, nil) assert.NotEqual(t, converted, nil) - assert.MatchRegex(t, converted.WorkflowStart, "start--.*") - assert.MatchRegex(t, converted.WorkflowException, "end--.*") + 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) diff --git a/pkg/conversion/format.go b/pkg/conversion/format.go index 3a2dd6131..00dd33f45 100644 --- a/pkg/conversion/format.go +++ b/pkg/conversion/format.go @@ -6,11 +6,6 @@ type TargetFormat int const ( FormatBpmn TargetFormat = iota - FormatSplunk - FormatMisp - FormatStix - FormatOpenC2 - FormatTaxii FormatUnknown ) @@ -24,10 +19,6 @@ func read_format(format string) TargetFormat { switch format { case "bpmn": return FormatBpmn - case "misp": - return FormatMisp - case "splunk": - return FormatSplunk } return FormatUnknown } diff --git a/pkg/conversion/misp_conversion.go b/pkg/conversion/misp_conversion.go deleted file mode 100644 index 81a217c72..000000000 --- a/pkg/conversion/misp_conversion.go +++ /dev/null @@ -1,17 +0,0 @@ -package conversion - -import ( - "errors" - "soarca/pkg/models/cacao" -) - -type MispConverter struct { -} - -func (MispConverter) Convert(input []byte, filename string) (*cacao.Playbook, error) { - return nil, errors.New("Unimplemented") - -} -func NewMispConverter() MispConverter { - return MispConverter{} -} diff --git a/pkg/conversion/splunk_conversion.go b/pkg/conversion/splunk_conversion.go deleted file mode 100644 index 482964cfe..000000000 --- a/pkg/conversion/splunk_conversion.go +++ /dev/null @@ -1,17 +0,0 @@ -package conversion - -import ( - "errors" - "soarca/pkg/models/cacao" -) - -type SplunkConverter struct { -} - -func (SplunkConverter) Convert(input []byte, filename string) (*cacao.Playbook, error) { - return nil, errors.New("Unimplemented") - -} -func NewSplunkConverter() SplunkConverter { - return SplunkConverter{} -} From 78d1ad2e18f297463577ce71fdd93d667e25fa4d Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 29 Aug 2025 13:21:53 +0200 Subject: [PATCH 11/15] Added tests --- test/conversion/conversion_test.go | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 test/conversion/conversion_test.go diff --git a/test/conversion/conversion_test.go b/test/conversion/conversion_test.go new file mode 100644 index 000000000..8b5fcf623 --- /dev/null +++ b/test/conversion/conversion_test.go @@ -0,0 +1,87 @@ +package conversion + +import ( + "os" + "soarca/pkg/conversion" + "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 := conversion.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 %s", 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 name %s", 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) +} From 6d621ac9773b757e8f55746fdec0efde901be076 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 29 Aug 2025 13:24:50 +0200 Subject: [PATCH 12/15] fixed lint errors --- pkg/conversion/bpmn_conversion.go | 4 +++- pkg/conversion/conversion_test.go | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn_conversion.go index ee562a106..0b0f11f32 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn_conversion.go @@ -263,7 +263,9 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.Playbook) error { log.Info("Implementing start task ", process.start_task.Id) - process.start_task.implement(playbook, converter) + 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 { diff --git a/pkg/conversion/conversion_test.go b/pkg/conversion/conversion_test.go index 1412aa34c..6ce00be38 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -46,6 +46,7 @@ func Test_bpmn_format_control(t *testing.T) { 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) From 90baa204195048c6b0af23bc462767d65fb1721b Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 31 Oct 2025 09:46:00 +0100 Subject: [PATCH 13/15] Update makefile rules This puts both binaries into a simple rule. This does complicate things if we add other targets in build/, but for now this works fine. --- makefile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/makefile b/makefile index 4de2bdb7d..d676e4765 100644 --- a/makefile +++ b/makefile @@ -16,9 +16,10 @@ swagger: 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 - CGO_ENABLED=0 go build -o ./build/soarca-conversion $(GOFLAGS) ./cmd/soarca-conversion/main.go +build: swagger build/soarca build/soarca-conversion + +build/%: + CGO_ENABLED=0 go build -o $@ $(GOFLAGS) ./cmd/$(@F)/main.go test: swagger go test ./pkg/... -v From 82377c58473ca9b459b75fbcd106b0c0f7abf7d8 Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 19 Dec 2025 14:47:30 +0100 Subject: [PATCH 14/15] Small changes based on code review This includes some points where code was put in more correct locations, and some extra testing. --- cmd/soarca-conversion/main.go | 11 + pkg/conversion/{ => bpmn}/bpmn_conversion.go | 156 ++++----- pkg/conversion/conversion_test.go | 14 +- pkg/conversion/converter.go | 9 + pkg/conversion/format.go | 24 -- .../{conversion.go => perform_conversion.go} | 19 +- pkg/models/conversion/format.go | 8 + pkg/utils/conversion/conversion.go | 20 ++ pkg/utils/conversion/playbook.go | 36 ++ test/conversion/cisagov_example.bpmn | 331 ++++++++++++++++++ test/conversion/control_gates.bpmn.json | 1 + test/conversion/conversion_test.go | 21 +- test/conversion/simple_ssh.bpmn.json | 1 + 13 files changed, 517 insertions(+), 134 deletions(-) rename pkg/conversion/{ => bpmn}/bpmn_conversion.go (70%) create mode 100644 pkg/conversion/converter.go delete mode 100644 pkg/conversion/format.go rename pkg/conversion/{conversion.go => perform_conversion.go} (52%) create mode 100644 pkg/models/conversion/format.go create mode 100644 pkg/utils/conversion/conversion.go create mode 100644 pkg/utils/conversion/playbook.go create mode 100644 test/conversion/cisagov_example.bpmn create mode 100644 test/conversion/control_gates.bpmn.json create mode 100644 test/conversion/simple_ssh.bpmn.json diff --git a/cmd/soarca-conversion/main.go b/cmd/soarca-conversion/main.go index d30b5b63c..6b4fe2976 100644 --- a/cmd/soarca-conversion/main.go +++ b/cmd/soarca-conversion/main.go @@ -32,6 +32,11 @@ var ( 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) @@ -44,9 +49,15 @@ func main() { 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 == "" { diff --git a/pkg/conversion/bpmn_conversion.go b/pkg/conversion/bpmn/bpmn_conversion.go similarity index 70% rename from pkg/conversion/bpmn_conversion.go rename to pkg/conversion/bpmn/bpmn_conversion.go index 0b0f11f32..6fbcf9319 100644 --- a/pkg/conversion/bpmn_conversion.go +++ b/pkg/conversion/bpmn/bpmn_conversion.go @@ -8,8 +8,8 @@ import ( "slices" "soarca/internal/logger" "soarca/pkg/models/cacao" + util "soarca/pkg/utils/conversion" "strings" - "time" "github.com/google/uuid" ) @@ -25,6 +25,15 @@ type BpmnConverter struct { 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"` @@ -46,6 +55,7 @@ type BpmnFlow struct { Name string `xml:"name,attr"` IsAssociation bool } + type BpmnGatewayKind int const ( @@ -82,37 +92,15 @@ func (converter BpmnConverter) Convert(input []byte, filename string) (*cacao.Pl if len(definitions.Processes) == 0 { return nil, errors.New("BPMN file does not have any processes") } - playbook := cacao.NewPlaybook() - playbook.SpecVersion = cacao.CACAO_VERSION_2 - playbook.Type = "playbook" - playbook.ID = fmt.Sprintf("playbook--%s", uuid.New()) - playbook.CreatedBy = fmt.Sprintf("identity--%s", uuid.New()) - playbook.Name = clean_filename(filename) - playbook.Created = time.Now().UTC() - playbook.Modified = time.Now().UTC() - playbook.ValidFrom = time.Now().UTC() - playbook.ValidUntil = time.Now().UTC() + playbook, soarca_name, soarca_manual_name := util.NewSoarcaPlaybook(clean_filename(filename), []string{"notification"}) playbook.Description = fmt.Sprintf("CACAO playbook converted from %s", filename) - soarca_name := fmt.Sprintf("soarca--%s", uuid.New()) - playbook.PlaybookTypes = []string{"notification"} - soarca_manual_name := fmt.Sprintf("soarca-manual--%s", uuid.New()) converter.translation["soarca"] = soarca_name converter.translation["soarca-manual"] = soarca_manual_name - 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"}) playbook.TargetDefinitions = cacao.NewAgentTargets( cacao.AgentTarget{ ID: fmt.Sprintf("individual--%s", uuid.New()), Type: "individual", - Name: "CHANGE THIS"}) + Name: ""}) playbook.Workflow = make(cacao.Workflow) converter.process = &definitions.Processes[0] if err := converter.implement(definitions.Processes[0], playbook); err != nil { @@ -139,10 +127,10 @@ type BpmnProcess struct { annotations []BpmnAnnotation } -func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { +func (process *BpmnProcess) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { start_name := start.Name for { - item, err := d.Token() + item, err := decoder.Token() if err != nil { return err } @@ -150,104 +138,104 @@ func (p *BpmnProcess) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error case xml.StartElement: switch item_type.Name.Local { case "startEvent": - err = d.DecodeElement(&p.start_task, &item_type) + err = decoder.DecodeElement(&process.start_task, &item_type) if err != nil { return err } case "endEvent": end_task := BpmnEndEvent{} - err = d.DecodeElement(&end_task, &item_type) - p.end_tasks = append(p.end_tasks, end_task) + 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 = d.DecodeElement(flow, &item_type) + err = decoder.DecodeElement(flow, &item_type) flow.IsAssociation = false if err != nil { return err } - p.flows = append(p.flows, *flow) + process.flows = append(process.flows, *flow) case "association": flow := new(BpmnFlow) - err = d.DecodeElement(flow, &item_type) + err = decoder.DecodeElement(flow, &item_type) flow.IsAssociation = true if err != nil { return err } - p.flows = append(p.flows, *flow) + process.flows = append(process.flows, *flow) case "scriptTask": task := new(BpmnTask) task.Kind = "script" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "task": task := new(BpmnTask) task.Kind = "task" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "serviceTask": task := new(BpmnTask) task.Kind = "service" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "sendTask": task := new(BpmnTask) task.Kind = "send" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "userTask": task := new(BpmnTask) task.Kind = "user" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "businessRuleTask": task := new(BpmnTask) task.Kind = "business rule" - err = d.DecodeElement(task, &item_type) + err = decoder.DecodeElement(task, &item_type) if err != nil { return err } - p.tasks = append(p.tasks, *task) + process.tasks = append(process.tasks, *task) case "exclusiveGateway": gateway := new(BpmnGateway) - err = d.DecodeElement(gateway, &item_type) + err = decoder.DecodeElement(gateway, &item_type) gateway.Kind = GatewayKindExclusive if err != nil { return err } - p.gateways = append(p.gateways, *gateway) + process.gateways = append(process.gateways, *gateway) case "parallelGateway": gateway := new(BpmnGateway) - err = d.DecodeElement(gateway, &item_type) + err = decoder.DecodeElement(gateway, &item_type) gateway.Kind = GatewayKindParallel if err != nil { return err } - p.gateways = append(p.gateways, *gateway) + process.gateways = append(process.gateways, *gateway) case "textAnnotation": annotation := new(BpmnAnnotation) - err = d.DecodeElement(annotation, &item_type) + err = decoder.DecodeElement(annotation, &item_type) if err != nil { return err } - p.annotations = append(p.annotations, *annotation) + process.annotations = append(process.annotations, *annotation) case "intermediateThrowEvent", "intermediateCatchEvent": return fmt.Errorf("throw/catch mechanism is currently not implemented in SOARCA") default: @@ -295,12 +283,26 @@ func (converter *BpmnConverter) implement(process BpmnProcess, playbook *cacao.P } func (task BpmnTask) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { - name := fmt.Sprintf("action--%s", uuid.New()) - converter.translation[task.Id] = name - step := cacao.Step{Type: "action", Name: task.Name, Commands: make([]cacao.Command, 0)} - step.Commands = append(step.Commands, cacao.Command{Type: "manual", Command: task.Name}) + 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[name] = step + 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 } @@ -368,48 +370,22 @@ func (flow BpmnFlow) implement_flow(playbook *cacao.Playbook, converter *BpmnCon return nil } -func (end_event BpmnEndEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { - name := fmt.Sprintf("end--%s", uuid.New()) - converter.translation[end_event.Id] = name - step := cacao.Step{Type: "end"} - playbook.Workflow[name] = step - playbook.WorkflowException = name - return nil -} -func (start_event BpmnStartEvent) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { - name := fmt.Sprintf("start--%s", uuid.New()) - converter.translation[start_event.Id] = name - step := cacao.Step{Type: "start"} - playbook.Workflow[name] = step - playbook.WorkflowStart = name - return nil -} func (gateway BpmnGateway) implement(playbook *cacao.Playbook, converter *BpmnConverter) error { switch gateway.Kind { case GatewayKindExclusive: - return gateway.implement_exclusive(playbook, converter) + return gateway.implement_gateway("if-condition", cacao.StepTypeIfCondition, playbook, converter) case GatewayKindParallel: - return gateway.implement_parallel(playbook, converter) - } - return nil -} -func (gateway BpmnGateway) implement_exclusive(playbook *cacao.Playbook, converter *BpmnConverter) error { - condition := cacao.Step{ - Type: cacao.StepTypeIfCondition, - Condition: gateway.Name, + return gateway.implement_gateway("parallel", cacao.StepTypeParallel, playbook, converter) } - name := fmt.Sprintf("if-condition--%s", uuid.New()) - converter.translation[gateway.Id] = name - playbook.Workflow[name] = condition return nil } -func (gateway BpmnGateway) implement_parallel(playbook *cacao.Playbook, converter *BpmnConverter) error { +func (gateway BpmnGateway) implement_gateway(step_id_prefix string, condition_type string, playbook *cacao.Playbook, converter *BpmnConverter) error { condition := cacao.Step{ - Type: cacao.StepTypeParallel, + Type: condition_type, Condition: gateway.Name, } - name := fmt.Sprintf("parallel--%s", uuid.New()) - converter.translation[gateway.Id] = name - playbook.Workflow[name] = condition + 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 index 6ce00be38..1c7ff857b 100644 --- a/pkg/conversion/conversion_test.go +++ b/pkg/conversion/conversion_test.go @@ -3,7 +3,9 @@ package conversion import ( "encoding/json" "os" + model "soarca/pkg/models/conversion" "soarca/pkg/models/validator" + util "soarca/pkg/utils/conversion" "strings" "testing" @@ -11,14 +13,14 @@ import ( ) func Test_read_format(t *testing.T) { - assert.Equal(t, read_format("bpmn"), FormatBpmn) - assert.Equal(t, read_format(""), FormatUnknown) - assert.Equal(t, read_format("cacao"), FormatUnknown) - assert.Equal(t, read_format("bpnm"), FormatUnknown) - assert.Equal(t, read_format("?"), FormatUnknown) + 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, guess_format("x.bpmn"), FormatBpmn) + assert.Equal(t, util.GuessFormat("x.bpmn"), model.FormatBpmn) } func Test_bpmn_format(t *testing.T) { 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/format.go b/pkg/conversion/format.go deleted file mode 100644 index 00dd33f45..000000000 --- a/pkg/conversion/format.go +++ /dev/null @@ -1,24 +0,0 @@ -package conversion - -import "strings" - -type TargetFormat int - -const ( - FormatBpmn TargetFormat = iota - FormatUnknown -) - -func guess_format(filename string) TargetFormat { - if strings.HasSuffix(filename, "bpmn") { - return FormatBpmn - } - return FormatUnknown -} -func read_format(format string) TargetFormat { - switch format { - case "bpmn": - return FormatBpmn - } - return FormatUnknown -} diff --git a/pkg/conversion/conversion.go b/pkg/conversion/perform_conversion.go similarity index 52% rename from pkg/conversion/conversion.go rename to pkg/conversion/perform_conversion.go index e75f29483..8abdabb3f 100644 --- a/pkg/conversion/conversion.go +++ b/pkg/conversion/perform_conversion.go @@ -2,27 +2,26 @@ 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 TargetFormat + var format model.TargetFormat if format_string == "" { - format = guess_format(input_filename) + format = util.GuessFormat(input_filename) } else { - format = read_format(format_string) + format = util.ReadFormat(format_string) } - if format == FormatUnknown { + if format == model.FormatUnknown { return nil, errors.New("could not deduce input file type") } var converter IConverter switch format { - case FormatBpmn: - converter = NewBpmnConverter() + case model.FormatBpmn: + converter = bpmn_conversion.NewBpmnConverter() } return converter.Convert(input, input_filename) } - -type IConverter interface { - Convert(input []byte, filename string) (*cacao.Playbook, error) -} 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.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 index 8b5fcf623..347ca6da4 100644 --- a/test/conversion/conversion_test.go +++ b/test/conversion/conversion_test.go @@ -2,7 +2,7 @@ package conversion import ( "os" - "soarca/pkg/conversion" + bpmn "soarca/pkg/conversion/bpmn" "soarca/pkg/models/cacao" "testing" "time" @@ -13,7 +13,7 @@ import ( func loadPlaybook(t *testing.T, filename string) *cacao.Playbook { input, err := os.ReadFile(filename) assert.Nil(t, err) - playbook, err := conversion.NewBpmnConverter().Convert(input, filename) + playbook, err := bpmn.NewBpmnConverter().Convert(input, filename) assert.Nil(t, err) return playbook } @@ -27,7 +27,7 @@ func nextSteps(t *testing.T, step cacao.Step, playbook *cacao.Playbook) []cacao. } 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 %s", 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 { @@ -36,7 +36,7 @@ func findStepByName[S ~[]cacao.Step](t *testing.T, step_name string, steps S) *c return &step } } - assert.Fail(t, "Could not find name %s", step_name) + assert.Fail(t, "Could not find step", step_name) return nil } @@ -85,3 +85,16 @@ func TestSimpleSshConversion(t *testing.T) { 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.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 From 0ce93d84ee178871759a5d6b9fbf0bf493575f6d Mon Sep 17 00:00:00 2001 From: Thijs Heijligenberg Date: Fri, 19 Dec 2025 14:54:41 +0100 Subject: [PATCH 15/15] Update makefile to have one go build rule This works for both build/soarca and build/soarca-conversion, and properly depends on all go files to detect rebuilds. The default build target always triggers this, as swag will touch api/docs.go; filtering this out broke the dependency for reasons beyond comprehension, but since builds are quite quick this is okay for now. --- makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/makefile b/makefile index d676e4765..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/*/))) @@ -18,7 +18,7 @@ lint: swagger build: swagger build/soarca build/soarca-conversion -build/%: +build/%: $(wildcard **/*.go) CGO_ENABLED=0 go build -o $@ $(GOFLAGS) ./cmd/$(@F)/main.go test: swagger