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