diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 52888645..aa430bb6 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -15,8 +15,9 @@ We will use HTTP status codes https://en.wikipedia.org/wiki/List_of_HTTP_status_ ```plantuml @startuml -protocol Reporter { +protocol Manual { GET /manual + GET /manual/{execution-id}/{step-id} POST /manual/continue } @enduml @@ -33,7 +34,7 @@ Get all pending manual actions objects that are currently waiting in SOARCA. None ##### Response -200/OK with payload list of: +200/OK with body a list of: @@ -45,8 +46,8 @@ None |step_id |UUID |string |The id of the step executed by the execution |description |description of the step|string |The description from the workflow step |command |command |string |The command for the agent either command -|command_is_base64 |true \| false |bool |Indicate the command is in base 64 -|targets |cacao agent-target |dictionary |Map of [cacao agent-target](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256509) with the target(s) of this command +|command_is_base64 |true/false |bool |Indicates if the command is in B64 +|target |cacao agent-target |object |Map of [cacao agent-target](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256509) with the target(s) of this command |out_args |cacao variables |dictionary |Map of [cacao variables](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256555) handled in the step out args with current values and definitions @@ -97,7 +98,7 @@ Get pending manual actions objects that are currently waiting in SOARCA for spec None ##### Response -200/OK with payload: +200/OK with body: @@ -109,7 +110,7 @@ None |step_id |UUID |string |The id of the step executed by the execution |description |description of the step|string |The description from the workflow step |command |command |string |The command for the agent either command -|command_is_base64 |true \| false |bool |Indicate the command is in base 64 +|command_is_base64 |true/false |bool |Indicates if the command is in B64 |targets |cacao agent-target |dictionary |Map of [cacao agent-target](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256509) with the target(s) of this command |out_args |cacao variables |dictionary |Map of [cacao variables](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256555) handled in the step out args with current values and definitions @@ -154,7 +155,7 @@ None General error #### POST `/manual/continue` -Respond to manual command pending in SOARCA, if out_args are defined they must be filled in and returned in the payload body. Only value is required in the response of the variable. You can however return the entire object. Of the object does not match the original out_arg the call we be considered as failed. +Respond to manual command pending in SOARCA, if out_args are defined they must be filled in and returned in the payload body. Only value is required in the response of the variable. You can however return the entire object. If the object does not match the original out_arg, the call we be considered as failed. ##### Call payload |field |content |type | description | @@ -163,9 +164,8 @@ Respond to manual command pending in SOARCA, if out_args are defined they must b |execution_id |UUID |string |The id of the execution |playbook_id |UUID |string |The id of the CACAO playbook executed by the execution |step_id |UUID |string |The id of the step executed by the execution -|response_status |enum |string |Can be either `success` or `failed` -|response_out_args |cacao variables |dictionary |Map of [cacao variables](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256555) handled in the step out args with current values and definitions - +|response_status |enum |string |`success` indicates successfull fulfilment of the manual request. `failure` indicates failed satisfaction of the request +|response_out_args |cacao variables |dictionary |Map of cacao variables names to cacao variable struct. Only name, type, and value are mandatory ```plantuml @@ -176,15 +176,15 @@ Respond to manual command pending in SOARCA, if out_args are defined they must b "execution_id" : "", "playbook_id" : "", "step_id" : "", - "response_status" : "success | failed", + "response_status" : "success | failure", "response_out_args": { "" : { - "type": "", + "type": "", "name": "", - "description": "", "value": "", - "constant": "", - "external": "" + "description": " (ignored)", + "constant": " (ignored)", + "external": " (ignored)" } } } @@ -193,7 +193,8 @@ Respond to manual command pending in SOARCA, if out_args are defined they must b ``` ##### Response -200/OK with payload: +200/OK with payload: +Generic execution information ##### Error 400/BAD REQUEST with payload: diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 8d0ae4f9..a6af5818 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -322,20 +322,199 @@ This example will start an operation that executes the ability with ID `36eecb80 ``` ### Manual capability -This capability executes [manual Commands](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256491) and provides them through the [SOARCA api](/docs/core-components/api-manual). +This capability executes [manual Commands](https://docs.oasis-open.org/cacao/security-playbooks/v2.0/cs01/security-playbooks-v2.0-cs01.html#_Toc152256491) and provides them natively through the [SOARCA api](/docs/core-components/api-manual), though other integrations are possible. - +The manual step should provide a timeout. SOARCA will by default use a timeout of 10 minutes. If a timeout occurs, the step is considered as failed. +#### Manual capability architecture +In essence, executing a manual command involves the following actions: +1. A message, the `command` of a manual command, is posted *somewhere*, *somehow*, together with the variables of which values is expected to be assigned or updated (if any). +2. The playbook execution stops, waiting for *something* to respond to the message with the variables values. +3. Once something replies, the variables are streamed inside the playbook execution and handled accordingly. +It should be possible to post a manual command message anywhere and in any way, and allow anything to respond back. Hence, SOARCA adopts a flexible architecture to accomodate different ways of manual *interactions*. Below a view of the architecture. +When a playbook execution hits an Action step with a manual command, the *ManualCapability* will queue the instruction into the *CapabilityInteraction* module. The module does essentially three things: +1. it stores the status of the manual command, and implements the SOARCA API interactions with the manual command. +2. If manual integrations are defined for the SOARCA instance, the *CapabilityInteraction* module notifies the manual integration modules, so that they can handle the manual command in turn. +3. It waits for the manual command to be satisfied either via SOARCA APIs, or via manual integrations. The first to respond amongst the two, resolves the manual command. The resolution of the command may or may not assign new values to variables in the playbook. Finally the *CapabilityInteraction* module replies to the *ManualCommand* module. + +Ultimately the *ManualCapability* then completes its execution, having eventually updated the values for the variables in the outArgs of the command. Timeouts or errors are handled opportunely. + +```plantuml +@startuml +set separator :: + +class ManualCommand + +protocol ManualAPI { + GET /manual + GET /manual/{exec-id}/{step-id} + POST /manual/continue +} + +interface ICapability{ + Execute() +} + +interface ICapabilityInteraction{ + Queue(command InteractionCommand, manualComms ManualCapabilityCommunication) +} + +interface IInteracionStorage{ + GetPendingCommands() []CommandData + GetPendingCommand(execution.metadata) CommandData + PostContinue(execution.metadata) ExecutionInfo +} + +interface IInteractionIntegrationNotifier { + Notify(command InteractionIntegrationCommand, channel manualCapabilityCommunication.Channel) +} + +class Interaction { + notifiers []IInteractionIntegrationNotifier + storage map[executionId]map[stepId]InteractionStorageEntry +} +class ThirdPartyManualIntegration + + +ManualCommand .up.|> ICapability +ManualCommand -down-> ICapabilityInteraction +Interaction .up.|> ICapabilityInteraction +Interaction .up.|> IInteracionStorage + +ManualAPI -down-> IInteracionStorage + +Interaction -right-> IInteractionIntegrationNotifier +ThirdPartyManualIntegration .up.|> IInteractionIntegrationNotifier + + +``` + +The default and internally-supported way to interact with the manual step is through SOARCA's [manual api](/docs/core-components/api-manual). +Besides SOARCA's [manual api](/docs/core-components/api-manual), SOARCA is designed to allow for configuration of additional ways that a manual command should be executed. In particular, there can be *one* manual integration (besides the native manual APIs) per running SOARCA instance. +Integration's code should implement the *IInteractionIntegrationNotifier* interface, returning the result of the manual command execution in form of an `InteractionIntegrationResponse` object, into the respective channel. + +The diagram below displays in some detail the way the manual interactions components work. + +```plantuml +@startuml +control "ManualCommand" as manual +control "Interaction" as interaction +control "ManualAPI" as api +control "ThirdPartyManualIntegration" as 3ptool +participant "Integration" as integration + +-> manual : ...manual command +manual -> interaction : Queue(command, capabilityChannel, timeoutContext) +manual -> manual : idle wait on capabilityChannel +activate manual + +activate interaction +interaction -> interaction : save pending manual command +interaction ->> 3ptool : Notify(command, capabilityChannel, timeoutContext) +3ptool <--> integration : custom handling command posting +deactivate interaction + +alt Command Response + + group Native ManualAPI flow + api -> interaction : GetPendingCommands() + activate interaction + activate api + api -> interaction : GetPendingCommand(execution.metadata) + api -> interaction : PostContinue(ManualOutArgsUpdate) + interaction -> interaction : build InteractionResponse + deactivate api + interaction --> manual : capabilityChannel <- InteractionResponse + manual ->> interaction : timeoutContext.Cancel() event + interaction -> interaction : de-register pending command + deactivate interaction + manual ->> 3ptool : timeoutContext.Deadline() event + activate 3ptool + 3ptool <--> integration : custom handling command completed + deactivate manual + <- manual : ...continue execution + deactivate 3ptool + deactivate integration + end +else + group Third Party Integration flow + integration --> 3ptool : custom handling command response + activate manual + activate integration + deactivate integration + activate 3ptool + 3ptool -> 3ptool : build InteractionResponse + 3ptool --> manual : capabilityChannel <- InteractionResponse + deactivate 3ptool + manual ->> interaction : timeoutContext.Cancel() event + activate interaction + interaction -> interaction : de-register pending command + deactivate interaction + manual ->> 3ptool : timeoutContext.Deadline() event + activate 3ptool + 3ptool <--> integration : custom handling command completed + deactivate 3ptool + activate integration + deactivate integration + <- manual : ...continue execution + deactivate manual + deactivate integration + end +end + +@enduml +``` + +Note that whoever resolves the manual command first, whether via the manualAPI, or a third party integration, then the command results are returned to the workflow execution, and the manual command is removed from the pending list. Hence, if a manual command is resolved e.g. via the manual integration, a postContinue API call for that same command will not go through, as the command will have been resolved already, and hence removed from the registry of pending manual commands. + +The diagram below shows instead what happens when a timeout occurs for the manual command. + +```plantuml +@startuml +control "ManualCommand" as manual +control "Interaction" as interaction +control "ManualAPI" as api +control "ThirdPartyManualIntegration" as 3ptool +participant "Integration" as integration + +-> manual : ...manual command +manual -> interaction : Queue(command, capabilityChannel, timeoutContext) +manual -> manual : idle wait on capabilityChannel +activate manual + +activate interaction +interaction -> interaction : save pending manual command +interaction ->> 3ptool : Notify(command, capabilityChannel, timeoutContext) +3ptool --> integration : custom handling command posting +deactivate interaction + +group Command execution times out + manual -> manual : timeoutContext.Deadline() + manual ->> interaction : timeoutContext.Deadline() event + manual ->> 3ptool : timeoutContext.Deadline() event + 3ptool --> integration : custom handling command timed-out view + activate interaction + interaction -> interaction : de-register pending command + <- manual : ...continue execution + deactivate manual + ... + api -> interaction : GetPendingCommand(execution.metadata) + interaction -> api : no pending command (404) +end + + +@enduml +``` #### Success and failure -In SOARCA the manual step is considered successful if a response is made through the [manual api](/docs/core-components/api-manual). The manual command can specify a timeout but if none is specified SOARCA will use a default timeout of 10 minutes. If a timeout occurs the step is considered as failed and SOARCA will return an error to the decomposer. +In SOARCA the manual step is considered successful if a response is made through the [manual api](/docs/core-components/api-manual), or an integration. The manual command can specify a timeout, but if none is specified SOARCA will use a default timeout of 10 minutes. If a timeout occurs the step is considered as failed and SOARCA will return an error to the decomposer. #### Variables diff --git a/examples/manual-playbook.json b/examples/manual-playbook.json new file mode 100644 index 00000000..3dd75bda --- /dev/null +++ b/examples/manual-playbook.json @@ -0,0 +1,89 @@ +{ + "type": "playbook", + "spec_version": "cacao-2.0", + "id": "playbook--fe65ef7b-e8b1-4ed9-ba60-3c380ae5ab28", + "name": "Example manual", + "description": "This playbook is to demonstrate the manual command definition", + "playbook_types": [ + "notification" + ], + "created_by": "identity--ac3c0258-7a81-46e7-a2ae-d34b6d06cc54", + "created": "2025-01-21T14:14:23.263Z", + "modified": "2025-01-21T14:14:23.263Z", + "revoked": false, + "valid_from": "2023-11-20T15:56:00.123Z", + "valid_until": "2123-11-20T15:56:00.123Z", + "priority": 1, + "severity": 1, + "impact": 1, + "labels": [ + "soarca", + "manual" + ], + "external_references": [ + { + "description": "TNO COSSAS" + } + ], + "workflow_start": "start--9e7d62b2-88ac-4656-94e1-dbd4413ba008", + "workflow_exception": "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958", + "workflow": { + "start--9e7d62b2-88ac-4656-94e1-dbd4413ba008": { + "name": "Start example flow for manual command", + "on_completion": "action--eb9372d4-d524-49fc-bf24-be26ea084779", + "type": "start" + }, + "action--eb9372d4-d524-49fc-bf24-be26ea084779": { + "name": "manual", + "description": "Instruction to the operator to be executed manually", + "step_variables": { + "__hyperspeed_ready__": { + "type": "string", + "description": "set value to true or false when the request is completed", + "constant": false, + "external": false + } + }, + "on_completion": "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958", + "type": "action", + "commands": [ + { + "type": "manual", + "command": "prepare Falcon for hyperspeed jump" + } + ], + "agent": "soarca-manual-capability--7b0e98db-fa93-42aa-8511-e871c65131b1", + "targets": [ + "individual--9d1f6217-34d5-435c-b29a-6a1af6b664d9" + ], + "out_args": [ + "__hyperspeed_ready__" + ] + }, + "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958": { + "name": "End Flow", + "type": "end" + } + }, + "agent_definitions": { + "soarca--00040001-1000-1000-a000-000100010001": { + "type": "soarca", + "name": "soarca-manual-capability" + }, + "soarca-manual-capability--7b0e98db-fa93-42aa-8511-e871c65131b1": { + "type": "soarca-manual-capability", + "name": "soarca-manual-capability", + "description": "SOARCA's manual command handler" + } + }, + "target_definitions": { + "individual--9d1f6217-34d5-435c-b29a-6a1af6b664d9": { + "type": "individual", + "name": "Luke Skywalker", + "description": "Darth Vader's son", + "location": { + "name": "Somewhere in a galaxy far far away" + } + } + } +} \ No newline at end of file diff --git a/internal/controller/controller.go b/internal/controller/controller.go index ddd0a755..b5840cc5 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -8,8 +8,10 @@ import ( "soarca/internal/database/memory" "soarca/internal/logger" "soarca/pkg/core/capability" + capabilityController "soarca/pkg/core/capability/fin/controller" "soarca/pkg/core/capability/fin/protocol" "soarca/pkg/core/capability/http" + "soarca/pkg/core/capability/manual/interaction" "soarca/pkg/core/capability/openc2" "soarca/pkg/core/capability/powershell" "soarca/pkg/core/capability/ssh" @@ -24,7 +26,6 @@ import ( "strconv" "strings" - capabilityController "soarca/pkg/core/capability/controller" finExecutor "soarca/pkg/core/capability/fin" thehive "soarca/pkg/integration/thehive/reporter" @@ -64,6 +65,9 @@ var mainCache = cache.Cache{} const defaultCacheSize int = 10 +// One manual interaction per SOARCA instance +var mainInteraction = interaction.New(registerManualIntegration()) + func (controller *Controller) NewDecomposer() decomposer.IDecomposer { ssh := new(ssh.SshCapability) capabilities := map[string]capability.ICapability{ssh.GetType(): ssh} @@ -254,6 +258,9 @@ func initializeCore(app *gin.Engine) error { return err } + // Manual capability native routes + routes.Manual(app, mainInteraction) + routes.Logging(app) routes.Swagger(app) @@ -274,6 +281,14 @@ func (controller *Controller) setupAndRunMqtt() error { return nil } +func registerManualIntegration() []interaction.IInteractionIntegrationNotifier { + // Manual interaction integrations will be initialized here when implemented + // Here we should check ENV variables, see if a manual interaction integration is selected, + // And populate the returned array via generating an instance of the notifier associated with + // the integration - which should be found in the integration code. + return []interaction.IInteractionIntegrationNotifier{} +} + func initializeIntegrationTheHiveReporting() downstreamReporter.IDownStreamReporter { initTheHiveReporter, _ := strconv.ParseBool(utils.GetEnv("THEHIVE_ACTIVATE", "false")) if !initTheHiveReporter { diff --git a/pkg/api/api.go b/pkg/api/api.go index 7c8f03c0..f1dc9698 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -10,6 +10,9 @@ import ( playbook_handler "soarca/pkg/api/playbook" reporter_handler "soarca/pkg/api/reporter" status_handler "soarca/pkg/api/status" + "soarca/pkg/core/capability/manual/interaction" + + manual_handler "soarca/pkg/api/manual" trigger_handler "soarca/pkg/api/trigger" @@ -45,6 +48,12 @@ func Reporter(app *gin.Engine, informer informer.IExecutionInformer) error { return nil } +func Manual(app *gin.Engine, interaction interaction.IInteractionStorage) { + log.Trace("Setting up manual routes") + manualHandler := manual_handler.NewManualHandler(interaction) + ManualRoutes(app, manualHandler) +} + func Api(app *gin.Engine, controller decomposer_controller.IController, database database.IController, @@ -126,3 +135,12 @@ func TriggerRoutes(route *gin.Engine, triggerHandler *trigger_handler.TriggerHan triggerRoutes.POST("/playbook/:id", triggerHandler.ExecuteById) } } + +func ManualRoutes(route *gin.Engine, manualHandler *manual_handler.ManualHandler) { + manualRoutes := route.Group("/manual") + { + manualRoutes.GET("/", manualHandler.GetPendingCommands) + manualRoutes.GET(":exec_id/:step_id", manualHandler.GetPendingCommand) + manualRoutes.POST("/continue", manualHandler.PostContinue) + } +} diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go new file mode 100644 index 00000000..16bc3b48 --- /dev/null +++ b/pkg/api/manual/manual_api.go @@ -0,0 +1,264 @@ +package manual + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "soarca/internal/logger" + "soarca/pkg/core/capability/manual/interaction" + "soarca/pkg/models/api" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + apiError "soarca/pkg/api/error" +) + +// Notes: +// A manual command in CACAO is simply the operation: +// { post_message; wait_for_response (returning a result) } +// The manual API expose general manual executions wide information +// Thus, we need a ManualHandler that uses an IInteractionStorage, implemented by interactionCapability +// The API routes will invoke the ManualHandler.interactionCapability interface instance +// The InteractionCapability manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) + +// It is always either only the internal API, or the internal API and ONE integration for manual. +// Env variable: can only have one active manual interactor. +// +// In light of this, for hierarchical and distributed playbooks executions (via multiple playbook actions), +// there will be ONE manual integration (besides internal API) per every ONE SOARCA instance. + +var log *logger.Log + +type Empty struct{} + +func init() { + log = logger.Logger(reflect.TypeOf(Empty{}).PkgPath(), logger.Info, "", logger.Json) +} + +type ManualHandler struct { + interactionCapability interaction.IInteractionStorage +} + +func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandler { + return &ManualHandler{interactionCapability: interaction} +} + +// manual +// +// @Summary get all pending manual commands that still needs values to be returned +// @Schemes +// @Description get all pending manual commands that still needs values to be returned +// @Tags manual +// @Accept json +// @Produce json +// @Success 200 {object} api.Execution +// @failure 400 {object} []api.InteractionCommandData +// @Router /manual/ [GET] +func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { + commands, err := manualHandler.interactionCapability.GetPendingCommands() + if err != nil { + log.Error(err) + apiError.SendErrorResponse(g, http.StatusInternalServerError, + "Failed get pending manual commands", + "GET /manual/", "") + return + } + + response := []api.InteractionCommandData{} + for _, command := range commands { + response = append(response, manualHandler.parseCommandInfoToResponse(command)) + } + + g.JSON(http.StatusOK, + response) +} + +// manual +// +// @Summary get a specific manual command that still needs a value to be returned +// @Schemes +// @Description get a specific manual command that still needs a value to be returned +// @Tags manual +// @Accept json +// @Produce json +// @Param exec_id path string true "execution ID" +// @Param step_id path string true "step ID" +// @Success 200 {object} api.InteractionCommandData +// @failure 400 {object} api.Error +// @Router /manual/{exec_id}/{step_id} [GET] +func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { + execution_id := g.Param("exec_id") + step_id := g.Param("step_id") + execId, err := uuid.Parse(execution_id) + if err != nil { + log.Error(err) + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to parse execution ID", + "GET /manual/"+execution_id+"/"+step_id, "") + return + } + + executionMetadata := execution.Metadata{ExecutionId: execId, StepId: step_id} + commandData, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) + if err != nil { + log.Error(err) + code := http.StatusBadRequest + if errors.Is(err, manual.ErrorPendingCommandNotFound{}) { + code = http.StatusNotFound + } + apiError.SendErrorResponse(g, code, + "Failed to provide pending manual command", + "GET /manual/"+execution_id+"/"+step_id, "") + return + } + + commandInfo := manualHandler.parseCommandInfoToResponse(commandData) + + g.JSON(http.StatusOK, commandInfo) +} + +// manual +// +// @Summary updates the value of a variable according to the manual interaction +// @Schemes +// @Description updates the value of a variable according to the manual interaction +// @Tags manual +// @Accept json +// @Produce json +// @Param exec_id path string true "execution ID" +// @Param step_id path string true "step ID" +// @Param type body string true "type" +// @Param outArgs body string true "execution ID" +// @Param execution_id body string true "playbook ID" +// @Param playbook_id body string true "playbook ID" +// @Param step_id body string true "step ID" +// @Param response_status body string true "response status" +// @Param response_out_args body cacao.Variables true "out args" +// @Success 200 {object} api.Execution +// @failure 400 {object} api.Error +// @Router /manual/continue [POST] +func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { + + jsonData, err := io.ReadAll(g.Request.Body) + if err != nil { + log.Error("failed") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to read json", + "POST /manual/continue", "") + return + } + + var outArgsUpdate api.ManualOutArgsUpdatePayload + decoder := json.NewDecoder(bytes.NewReader(jsonData)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&outArgsUpdate) + if err != nil { + log.Error("failed to unmarshal JSON") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to unmarshal JSON", + "POST /manual/continue", "") + return + } + + // Check if variable names match + ok := manualHandler.postContinueVariableNamesMatchCheck(outArgsUpdate.ResponseOutArgs) + if !ok { + log.Error("variable name mismatch") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Variable name mismatch", + "POST /manual/continue", "") + return + } + + interactionResponse, err := manualHandler.parseManualResponseToInteractionResponse(outArgsUpdate) + if err != nil { + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to parse response", + "POST /manual/continue", err.Error()) + } + + err = manualHandler.interactionCapability.PostContinue(interactionResponse) + if err != nil { + log.Error(err) + code := http.StatusBadRequest + msg := "Failed to post the continue request" + if errors.Is(err, manual.ErrorPendingCommandNotFound{}) { + code = http.StatusNotFound + msg = "Pending command not found" + } else if errors.Is(err, manual.ErrorNonMatchingOutArgs{}) { + code = http.StatusBadRequest + msg = "Provided out args don't match with expected" + } + apiError.SendErrorResponse(g, code, + msg, + "POST /manual/continue", "") + return + } + g.JSON( + http.StatusOK, + api.Execution{ + ExecutionId: uuid.MustParse(outArgsUpdate.ExecutionId), + PlaybookId: outArgsUpdate.PlaybookId, + }) +} + +// Utility +func (manualHandler *ManualHandler) parseCommandInfoToResponse(commandInfo manual.CommandInfo) api.InteractionCommandData { + commandText := commandInfo.Context.Command.Command + isBase64 := false + if len(commandInfo.Context.Command.CommandB64) > 0 { + commandText = commandInfo.Context.Command.CommandB64 + isBase64 = true + } + + response := api.InteractionCommandData{ + Type: "manual-command-info", + ExecutionId: commandInfo.Metadata.ExecutionId.String(), + PlaybookId: commandInfo.Metadata.PlaybookId, + StepId: commandInfo.Metadata.StepId, + Description: commandInfo.Context.Command.Description, + Command: commandText, + CommandIsBase64: isBase64, + Target: commandInfo.Context.Target, + OutVariables: commandInfo.OutArgsVariables, + } + + return response +} + +func (manualHandler *ManualHandler) parseManualResponseToInteractionResponse(response api.ManualOutArgsUpdatePayload) (manual.InteractionResponse, error) { + executionId, err := uuid.Parse(response.ExecutionId) + if err != nil { + return manual.InteractionResponse{}, err + } + + interactionResponse := manual.InteractionResponse{ + Metadata: execution.Metadata{ + ExecutionId: executionId, + PlaybookId: response.PlaybookId, + StepId: response.StepId, + }, + ResponseStatus: response.ResponseStatus, + OutArgsVariables: response.ResponseOutArgs, + ResponseError: nil, + } + + return interactionResponse, nil +} + +func (ManualHandler *ManualHandler) postContinueVariableNamesMatchCheck(outArgs cacao.Variables) bool { + ok := true + for varName, variable := range outArgs { + if varName != variable.Name { + ok = false + } + } + return ok +} diff --git a/pkg/core/capability/controller/controller.go b/pkg/core/capability/fin/controller/controller.go similarity index 100% rename from pkg/core/capability/controller/controller.go rename to pkg/core/capability/fin/controller/controller.go diff --git a/pkg/core/capability/controller/controller_test.go b/pkg/core/capability/fin/controller/controller_test.go similarity index 100% rename from pkg/core/capability/controller/controller_test.go rename to pkg/core/capability/fin/controller/controller_test.go diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go new file mode 100644 index 00000000..cfa69c93 --- /dev/null +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -0,0 +1,275 @@ +package interaction + +import ( + "errors" + "fmt" + "reflect" + "soarca/internal/logger" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" + ctxModel "soarca/pkg/models/utils/context" +) + +type Empty struct{} + +var component = reflect.TypeOf(Empty{}).PkgPath() +var log *logger.Log + +func init() { + log = logger.Logger(component, logger.Info, "", logger.Json) +} + +type IInteractionIntegrationNotifier interface { + Notify(command manual.InteractionIntegrationCommand, channel chan manual.InteractionResponse) +} + +type ICapabilityInteraction interface { + Queue(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) error +} + +type IInteractionStorage interface { + GetPendingCommands() ([]manual.CommandInfo, error) + // even if step has multiple manual commands, there should always be just one pending manual command per action step + GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) + PostContinue(response manual.InteractionResponse) error +} + +type InteractionController struct { + InteractionStorage map[string]map[string]manual.InteractionStorageEntry // Keyed on [executionID][stepID] + Notifiers []IInteractionIntegrationNotifier +} + +func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionController { + storage := map[string]map[string]manual.InteractionStorageEntry{} + return &InteractionController{ + InteractionStorage: storage, + Notifiers: manualIntegrations, + } +} + +// TODO: +// - Create typed error and pass back to API function for Storage interface fcns +// - Fix documeentation + +// ############################################################################ +// ICapabilityInteraction implementation +// ############################################################################ +func (manualController *InteractionController) Queue(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) error { + + err := manualController.registerPendingInteraction(command, manualComms.Channel) + if err != nil { + return err + } + + if _, ok := manualComms.TimeoutContext.Deadline(); !ok { + return errors.New("manual command does not have a deadline") + } + + // Copy and type conversion + integrationCommand := manual.InteractionIntegrationCommand(command) + + // One response channel for all integrations + integrationChannel := make(chan manual.InteractionResponse) + + for _, notifier := range manualController.Notifiers { + go notifier.Notify(integrationCommand, integrationChannel) + } + + // Async idle wait on command-specific channel closure + go manualController.handleManualCommandResponse(command, manualComms) + + return nil +} + +func (manualController *InteractionController) handleManualCommandResponse(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) { + log.Trace( + fmt.Sprintf( + "goroutine handling command response %s, %s has started", command.Metadata.ExecutionId.String(), command.Metadata.StepId)) + defer log.Trace( + fmt.Sprintf( + "goroutine handling command response %s, %s has ended", command.Metadata.ExecutionId.String(), command.Metadata.StepId)) + + // Wait for either timeout or response + <-manualComms.TimeoutContext.Done() + if manualComms.TimeoutContext.Err().Error() == ctxModel.ErrorContextTimeout { + log.Info("manual command timed out. deregistering associated pending command") + + err := manualController.removeInteractionFromPending(command.Metadata) + if err != nil { + log.Warning(err) + log.Warning("manual command not found among pending ones. should be already resolved") + return + } + } else if manualComms.TimeoutContext.Err().Error() == ctxModel.ErrorContextCanceled { + log.Info("manual command completed. deregistering associated pending command") + err := manualController.removeInteractionFromPending(command.Metadata) + if err != nil { + log.Warning(err) + log.Warning("manual command not found among pending ones. should be already resolved") + return + } + } +} + +// ############################################################################ +// IInteractionStorage implementation +// ############################################################################ +func (manualController *InteractionController) GetPendingCommands() ([]manual.CommandInfo, error) { + log.Trace("getting pending manual commands") + return manualController.getAllPendingCommandsInfo(), nil +} + +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) { + log.Trace("getting pending manual command") + interaction, err := manualController.getPendingInteraction(metadata) + return interaction.CommandInfo, err +} + +func (manualController *InteractionController) PostContinue(response manual.InteractionResponse) error { + log.Trace("completing manual command") + + // If not in there, it means it was already solved, or expired + pendingEntry, err := manualController.getPendingInteraction(response.Metadata) + if err != nil { + log.Warning(err) + return err + } + + warnings, err := manualController.validateMatchingOutArgs(pendingEntry, response.OutArgsVariables) + if err != nil { + return err + } + + //Then put outArgs back into manualCapabilityChannel + // Copy result and conversion back to interactionResponse format + log.Trace("pushing assigned variables in manual capability channel") + pendingEntry.Channel <- response + + if len(warnings) > 0 { + for _, warning := range warnings { + log.Warning(warning) + } + } + + return nil +} + +// ############################################################################ +// Utilities and functionalities +// ############################################################################ +func (manualController *InteractionController) registerPendingInteraction(command manual.CommandInfo, manualChan chan manual.InteractionResponse) error { + + commandInfo := manual.CommandInfo{ + Metadata: command.Metadata, + Context: command.Context, + OutArgsVariables: command.OutArgsVariables, + } + + execution, ok := manualController.InteractionStorage[commandInfo.Metadata.ExecutionId.String()] + + if !ok { + // It's fine, no entry for execution registered. Register execution and step entry + manualController.InteractionStorage[commandInfo.Metadata.ExecutionId.String()] = map[string]manual.InteractionStorageEntry{ + commandInfo.Metadata.StepId: { + CommandInfo: commandInfo, + Channel: manualChan, + }, + } + return nil + } + + // There is an execution entry + if _, ok := execution[commandInfo.Metadata.StepId]; ok { + // Error: there is already a pending manual command for the action step + err := fmt.Errorf( + "a manual step is already pending for execution %s, step %s. There can only be one pending manual command per action step.", + commandInfo.Metadata.ExecutionId.String(), commandInfo.Metadata.StepId) + log.Error(err) + return err + } + + // Execution exist, and Finally register pending command in existing execution + // Question: is it ever the case that the same exact step is executed in parallel branches? Then this code would not work + execution[commandInfo.Metadata.StepId] = manual.InteractionStorageEntry{ + CommandInfo: commandInfo, + Channel: manualChan, + } + + return nil +} + +func (manualController *InteractionController) getAllPendingCommandsInfo() []manual.CommandInfo { + allPendingInteractions := []manual.CommandInfo{} + for _, interactions := range manualController.InteractionStorage { + for _, interaction := range interactions { + allPendingInteractions = append(allPendingInteractions, interaction.CommandInfo) + } + } + return allPendingInteractions +} + +func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionStorageEntry, error) { + executionCommands, ok := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] + if !ok { + err := fmt.Sprintf("no pending commands found for execution %s", commandMetadata.ExecutionId.String()) + return manual.InteractionStorageEntry{}, manual.ErrorPendingCommandNotFound{Err: err} + } + interaction, ok := executionCommands[commandMetadata.StepId] + if !ok { + err := fmt.Sprintf("no pending commands found for execution %s -> step %s", + commandMetadata.ExecutionId.String(), + commandMetadata.StepId, + ) + return manual.InteractionStorageEntry{}, manual.ErrorPendingCommandNotFound{Err: err} + + } + return interaction, nil +} + +func (manualController *InteractionController) removeInteractionFromPending(commandMetadata execution.Metadata) error { + _, err := manualController.getPendingInteraction(commandMetadata) + if err != nil { + return err + } + // Get map of pending manual commands associated to execution + executionCommands := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] + // Delete stepID-linked pending command + delete(executionCommands, commandMetadata.StepId) + + // If no pending commands associated to the execution, delete the executions map + // This is done to keep the storage clean. + if len(executionCommands) == 0 { + delete(manualController.InteractionStorage, commandMetadata.ExecutionId.String()) + } + return nil +} + +func (manualController *InteractionController) validateMatchingOutArgs(pendingEntry manual.InteractionStorageEntry, responseOutArgs cacao.Variables) ([]string, error) { + warns := []string{} + var err error = nil + for varName, variable := range responseOutArgs { + // first check that out args provided match the variables + if _, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; !ok { + err = fmt.Errorf("provided out arg %s does not match any intended out arg", varName) + return warns, manual.ErrorNonMatchingOutArgs{Err: err.Error()} + + } + // then warn if any value outside "value" has changed + if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { + if variable.Constant != pending.Constant { + warns = append(warns, fmt.Sprintf("provided out arg %s has different value for 'Constant' property of intended out arg. This different value is ignored.", varName)) + } + if variable.Description != pending.Description { + warns = append(warns, fmt.Sprintf("provided out arg %s has different value for 'Description' property of intended out arg. This different value is ignored.", varName)) + } + if variable.External != pending.External { + warns = append(warns, fmt.Sprintf("provided out arg %s has different value for 'External' property of intended out arg. This different value is ignored.", varName)) + } + if variable.Type != pending.Type { + warns = append(warns, fmt.Sprintf("provided out arg %s has different value for 'Type' property of intended out arg. This different value is ignored.", varName)) + } + } + } + return warns, err +} diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go new file mode 100644 index 00000000..2e286af1 --- /dev/null +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -0,0 +1,669 @@ +package interaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "soarca/pkg/core/capability" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + manualModel "soarca/pkg/models/manual" + "sort" + "strings" + "testing" + "time" + + "github.com/go-playground/assert/v2" + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +func TestQueue(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer testCancel() + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: testCtx, + } + + // Call queue + err := interaction.Queue(testInteractionCommand, testCapComms) + if err != nil { + t.Log(err) + t.Fail() + } +} + +func TestQueueFailWithoutTimeout(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + + testCommand := manualModel.CommandInfo{} + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: context.WithoutCancel(context.Background()), + } + err := interaction.Queue(testCommand, testCapComms) + assert.Equal(t, err, errors.New("manual command does not have a deadline")) +} + +func TestQueueExitOnTimeout(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + timeout := 30 * time.Millisecond + testCtx, testCancel := context.WithTimeout(context.Background(), timeout) + defer testCancel() + + hook := NewTestLogHook() + log.Logger.AddHook(hook) + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: testCtx, + } + + err := interaction.Queue(testInteractionCommand, testCapComms) + if err != nil { + t.Log(err) + t.Fail() + } + + time.Sleep(50 * time.Millisecond) + + expectedLogEntry := "manual command timed out. deregistering associated pending command" + assert.NotEqual(t, len(hook.Entries), 0) + contains := false + for _, entry := range hook.Entries { + if strings.Contains(entry.Message, expectedLogEntry) { + contains = true + break + } + } + assert.Equal(t, contains, true) + +} + +func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + retrievedCommand, err := interaction.getPendingInteraction(testMetadata) + if err != nil { + t.Log(err) + t.Fail() + } + + //Channel + assert.Equal(t, + retrievedCommand.Channel, + testChan, + ) + + // ExecutionId + assert.Equal(t, + retrievedCommand.CommandInfo.Metadata.ExecutionId.String(), + testInteractionCommand.Metadata.ExecutionId.String(), + ) + // PlaybookId + assert.Equal(t, + retrievedCommand.CommandInfo.Metadata.PlaybookId, + testInteractionCommand.Metadata.PlaybookId, + ) + // StepId + assert.Equal(t, + retrievedCommand.CommandInfo.Metadata.StepId, + testInteractionCommand.Metadata.StepId, + ) + // Description + assert.Equal(t, + retrievedCommand.CommandInfo.Context.Command.Description, + testInteractionCommand.Context.Command.Description, + ) + // Command + assert.Equal(t, + retrievedCommand.CommandInfo.Context.Command.Command, + testInteractionCommand.Context.Command.Command, + ) + // CommandB64 + assert.Equal(t, + retrievedCommand.CommandInfo.Context.Command.CommandB64, + testInteractionCommand.Context.Command.CommandB64, + ) + // Target + assert.Equal(t, + retrievedCommand.CommandInfo.Context.Target, + testInteractionCommand.Context.Target, + ) + // OutArgs + assert.Equal(t, + retrievedCommand.CommandInfo.OutArgsVariables, + testInteractionCommand.OutArgsVariables, + ) +} + +func TestGetAllPendingInteractions(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + localTestInteractionCommand := testInteractionCommand + err := interaction.registerPendingInteraction(localTestInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + testNewInteractionCommand := localTestInteractionCommand + newExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testNewInteractionCommand.Metadata.ExecutionId = uuid.MustParse(newExecId) + + err = interaction.registerPendingInteraction(testNewInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + expectedInteractions := []manualModel.CommandInfo{localTestInteractionCommand, testNewInteractionCommand} + receivedInteractions := interaction.getAllPendingCommandsInfo() + + // Sort both slices by ExecutionId + sort.Slice(expectedInteractions, func(i, j int) bool { + return expectedInteractions[i].Metadata.ExecutionId.String() < expectedInteractions[j].Metadata.ExecutionId.String() + }) + sort.Slice(receivedInteractions, func(i, j int) bool { + return receivedInteractions[i].Metadata.ExecutionId.String() < receivedInteractions[j].Metadata.ExecutionId.String() + }) + + receivedInteractionsJson, err := json.MarshalIndent(receivedInteractions, "", " ") + if err != nil { + t.Log("failed to marshal received interactions") + t.Log(err) + t.Fail() + } + t.Log("received interactions") + t.Log(string(receivedInteractionsJson)) + + for i, receivedInteraction := range receivedInteractions { + if expectedInteractions[i].Metadata != receivedInteraction.Metadata { + err = fmt.Errorf("expected %v, but got %v", expectedInteractions, receivedInteractions) + t.Log(err) + t.Fail() + } + if !reflect.DeepEqual(expectedInteractions[i].OutArgsVariables, receivedInteraction.OutArgsVariables) { + err = fmt.Errorf("expected %v, but got %v", expectedInteractions, receivedInteractions) + t.Log(err) + t.Fail() + } + if !reflect.DeepEqual(expectedInteractions[i].Context, receivedInteraction.Context) { + err = fmt.Errorf("expected %v, but got %v", expectedInteractions[i].Context, receivedInteraction.Context) + t.Log(err) + t.Fail() + } + } +} + +func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + localTestInteractionCommand := testInteractionCommand + + err := interaction.registerPendingInteraction(localTestInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + testNewInteractionCommandSecond := localTestInteractionCommand + newStepId2 := "test_second_step_id" + testNewInteractionCommandSecond.Metadata.StepId = newStepId2 + + testNewInteractionCommandThird := localTestInteractionCommand + newStepId3 := "test_third_step_id" + testNewInteractionCommandThird.Metadata.StepId = newStepId3 + + err = interaction.registerPendingInteraction(testNewInteractionCommandSecond, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + err = interaction.registerPendingInteraction(testNewInteractionCommandThird, testChan) + if err != nil { + t.Log(err) + t.Fail() + } +} + +func TestCopyOutArgsToVars(t *testing.T) { + + interaction := New([]IInteractionIntegrationNotifier{}) + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer testCancel() + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: testCtx, + } + + // Call queue + err := interaction.Queue(testInteractionCommand, testCapComms) + if err != nil { + t.Log(err) + t.Fail() + } + + outArg := cacao.Variable{ + Type: "string", + Name: "var2", + Description: "this description will not make it to the returned var", + Value: "now the value is bananas", + Constant: true, // changed but won't be ported + External: true, // changed but won't be ported + } + + expectedVariable := cacao.Variable{ + Type: "string", + Name: "var2", + Value: "now the value is bananas", + } + + responseOutArgs := cacao.Variables{"var2": outArg} + + vars := responseOutArgs + assert.Equal(t, expectedVariable.Type, vars["var2"].Type) + assert.Equal(t, expectedVariable.Name, vars["var2"].Name) + assert.Equal(t, expectedVariable.Value, vars["var2"].Value) +} + +func TestPostContinueWarningsRaised(t *testing.T) { + + interaction := New([]IInteractionIntegrationNotifier{}) + timeout := 5 * time.Second + testCtx, testCancel := context.WithTimeout(context.Background(), timeout) + + hook := NewTestLogHook() + log.Logger.AddHook(hook) + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: testCtx, + } + + err := interaction.Queue(testInteractionCommand, testCapComms) + if err != nil { + t.Log(err) + t.Fail() + } + + outArg := cacao.Variable{ + Type: "banana", + Name: "var2", + Description: "this description will not make it to the returned var", + Value: "now the value is bananas", + Constant: true, // changed but won't be ported + External: true, // changed but won't be ported + } + + outArgsUpdate := manualModel.InteractionResponse{ + Metadata: testMetadata, + ResponseStatus: "success", + ResponseError: nil, + OutArgsVariables: cacao.Variables{"var2": outArg}, + } + + // Start a goroutine to read from the channel to avoid blocking + go func() { + for response := range testCapComms.Channel { + log.Trace("Received response:", response) + } + }() + + err = interaction.PostContinue(outArgsUpdate) + var expectedErr error = nil + time.Sleep(100 * time.Millisecond) + assert.Equal(t, err, expectedErr) + + // Simulating Manual Capability closing the channel and the context + close(testCapComms.Channel) + testCancel() + time.Sleep(300 * time.Millisecond) + + expectedLogEntry1 := "provided out arg var2 has different value for 'Constant' property of intended out arg. This different value is ignored." + expectedLogEntry2 := "provided out arg var2 has different value for 'Description' property of intended out arg. This different value is ignored." + expectedLogEntry3 := "provided out arg var2 has different value for 'External' property of intended out arg. This different value is ignored." + expectedLogEntry4 := "provided out arg var2 has different value for 'Type' property of intended out arg. This different value is ignored." + expectedLogs := []string{expectedLogEntry1, expectedLogEntry2, expectedLogEntry3, expectedLogEntry4} + + all := true + for _, expectedMessage := range expectedLogs { + containsAll := true + for _, entry := range hook.Entries { + if strings.Contains(entry.Message, expectedMessage) { + containsAll = true + break + } + if !strings.Contains(entry.Message, expectedMessage) { + containsAll = false + } + } + if !containsAll { + t.Logf("log message: '%s' not found in logged messages", expectedMessage) + all = false + break + } + } + + assert.NotEqual(t, len(hook.Entries), 0) + assert.Equal(t, all, true) + +} + +func TestPostContinueFailOnNonexistingVariable(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + timeout := 500 * time.Millisecond + testCtx, testCancel := context.WithTimeout(context.Background(), timeout) + + defer testCancel() + + hook := NewTestLogHook() + log.Logger.AddHook(hook) + + testCapComms := manualModel.ManualCapabilityCommunication{ + Channel: make(chan manualModel.InteractionResponse), + TimeoutContext: testCtx, + } + defer close(testCapComms.Channel) + + err := interaction.Queue(testInteractionCommand, testCapComms) + if err != nil { + t.Log(err) + t.Fail() + } + + outArg := cacao.Variable{ + Type: "string", + Name: "testNotExisting", + Value: "now the value is bananas", + } + + outArgsUpdate := manualModel.InteractionResponse{ + Metadata: testMetadata, + ResponseStatus: "success", + ResponseError: nil, + OutArgsVariables: cacao.Variables{"testNotExisting": outArg}, + } + + err = interaction.PostContinue(outArgsUpdate) + + expectedErr := manualModel.ErrorNonMatchingOutArgs{ + Err: fmt.Sprintf("provided out arg %s does not match any intended out arg", outArg.Name)} + + assert.Equal(t, err, expectedErr) +} + +func TestRegisterRetrieveNewExecutionNewPendingInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + testNewInteractionCommand := testInteractionCommand + newExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testNewInteractionCommand.Metadata.ExecutionId = uuid.MustParse(newExecId) + + err = interaction.registerPendingInteraction(testNewInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } +} + +func TestGetEmptyPendingCommand(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + emptyCommandInfo, err := interaction.GetPendingCommand(testMetadata) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution " + + "61a6c41e-6efc-4516-a242-dfbc5c89d562", + } + + assert.Equal(t, emptyCommandInfo, manualModel.CommandInfo{}) + assert.Equal(t, err, expectedErr) +} + +func TestFailOnRegisterSamePendingInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + err = interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := errors.New( + "a manual step is already pending for execution " + + "61a6c41e-6efc-4516-a242-dfbc5c89d562, step test_step_id. " + + "There can only be one pending manual command per action step.", + ) + assert.Equal(t, err, expectedErr) +} + +func TestFailOnRetrieveUnexistingExecutionInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + testDifferentMetadata := testMetadata + newExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testDifferentMetadata.ExecutionId = uuid.MustParse(newExecId) + + _, err := interaction.getPendingInteraction(testDifferentMetadata) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution 50b6d52c-6efc-4516-a242-dfbc5c89d421", + } + assert.Equal(t, err, expectedErr) +} + +func TestFailOnRetrieveNonExistingCommandInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + testDifferentMetadata := testMetadata + newStepId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testDifferentMetadata.StepId = newStepId + + _, err = interaction.getPendingInteraction(testDifferentMetadata) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution " + + "61a6c41e-6efc-4516-a242-dfbc5c89d562 -> " + + "step 50b6d52c-6efc-4516-a242-dfbc5c89d421", + } + assert.Equal(t, err, expectedErr) +} + +func TestRemovePendingInteraciton(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Log(err) + t.Fail() + } + + pendingCommand, err := interaction.getPendingInteraction(testMetadata) + if err != nil { + t.Log(err) + t.Fail() + } + assert.Equal(t, + pendingCommand.CommandInfo.Metadata.ExecutionId.String(), + testInteractionCommand.Metadata.ExecutionId.String(), + ) + assert.Equal(t, + pendingCommand.CommandInfo.Metadata.StepId, + testInteractionCommand.Metadata.StepId, + ) + + err = interaction.removeInteractionFromPending(testMetadata) + if err != nil { + t.Log(err) + t.Fail() + } + + err = interaction.removeInteractionFromPending(testMetadata) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution " + + "61a6c41e-6efc-4516-a242-dfbc5c89d562", + } + assert.Equal(t, err, expectedErr) +} + +// ############################################################################ +// Utils +// ############################################################################ + +type TestHook struct { + Entries []*logrus.Entry +} + +func (hook *TestHook) Levels() []logrus.Level { + return logrus.AllLevels +} + +func (hook *TestHook) Fire(entry *logrus.Entry) error { + hook.Entries = append(hook.Entries, entry) + return nil +} + +func NewTestLogHook() *TestHook { + return &TestHook{} +} + +var testUUIDStr string = "61a6c41e-6efc-4516-a242-dfbc5c89d562" +var testMetadata = execution.Metadata{ + ExecutionId: uuid.MustParse(testUUIDStr), + PlaybookId: "test_playbook_id", + StepId: "test_step_id", +} + +var testInteractionCommand = manualModel.CommandInfo{ + Metadata: testMetadata, + OutArgsVariables: cacao.Variables{ + "var2": { + Type: "string", + Name: "var2", + Description: "test variable", + Value: "", + Constant: false, + External: false, + }, + }, + Context: capability.Context{ + Command: cacao.Command{ + Type: "test_type", + Command: "test_command", + Description: "test_description", + CommandB64: "test_command_b64", + Version: "1.0", + PlaybookActivity: "test_activity", + Headers: cacao.Headers{}, + Content: "test_content", + ContentB64: "test_content_b64", + }, + Step: cacao.Step{ + Type: "test_type", + ID: "test_id", + Name: "test_name", + Description: "test_description", + Timeout: 1, + StepVariables: cacao.Variables{ + "var1": { + Type: "string", + Name: "var1", + Description: "test variable", + Value: "test_value_1", + Constant: false, + External: false, + }, + }, + OutArgs: cacao.OutArgs{"var2"}, + Commands: []cacao.Command{ + { + Type: "test_type", + Command: "test_command", + }, + }, + }, + Authentication: cacao.AuthenticationInformation{}, + Target: cacao.AgentTarget{ + ID: "test_id", + Type: "test_type", + Name: "test_name", + Description: "test_description", + }, + Variables: cacao.Variables{ + "var2": { + Type: "string", + Name: "var2", + Description: "test variable", + Value: "test_value_2", + Constant: false, + External: false, + }, + }, + }, +} diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index 5e697146..c377ee50 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -1,13 +1,15 @@ package manual import ( + "context" "errors" "reflect" "soarca/internal/logger" "soarca/pkg/core/capability" - "soarca/pkg/interaction" + "soarca/pkg/core/capability/manual/interaction" "soarca/pkg/models/cacao" "soarca/pkg/models/execution" + manualModel "soarca/pkg/models/manual" "time" ) @@ -22,10 +24,8 @@ const ( fallbackTimeout = time.Minute * 1 ) -func New(controller interaction.ICapabilityInteraction, - channel chan interaction.InteractionResponse) ManualCapability { - // channel := make(chan interaction.InteractionResponse) - return ManualCapability{interaction: controller, channel: channel} +func New(controller interaction.ICapabilityInteraction) ManualCapability { + return ManualCapability{interaction: controller} } func init() { @@ -34,7 +34,6 @@ func init() { type ManualCapability struct { interaction interaction.ICapabilityInteraction - channel chan interaction.InteractionResponse } func (manual *ManualCapability) GetType() string { @@ -45,14 +44,31 @@ func (manual *ManualCapability) Execute( metadata execution.Metadata, commandContext capability.Context) (cacao.Variables, error) { - command := interaction.InteractionCommand{Metadata: metadata, Context: commandContext} + command := manualModel.CommandInfo{ + Metadata: metadata, + Context: commandContext, + OutArgsVariables: commandContext.Variables.Select(commandContext.Step.OutArgs), + } + + timeout := manual.getTimeoutValue(commandContext.Step.Timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // One channel per Execute() invocation. Async manual capability Execute() invocations can thus + // use separate channels per each specific manual command, preventing manual returned args interfering + channel := make(chan manualModel.InteractionResponse) + defer close(channel) + + err := manual.interaction.Queue(command, manualModel.ManualCapabilityCommunication{ + Channel: channel, + TimeoutContext: ctx, + }) - err := manual.interaction.Queue(command, manual.channel) if err != nil { return cacao.NewVariables(), err } - result, err := manual.awaitUserInput(manual.getTimeoutValue(commandContext.Step.Timeout)) + result, err := manual.awaitUserInput(channel, ctx) if err != nil { return cacao.NewVariables(), err } @@ -60,17 +76,18 @@ func (manual *ManualCapability) Execute( } -func (manual *ManualCapability) awaitUserInput(timeout time.Duration) (cacao.Variables, error) { - timer := time.NewTimer(time.Duration(timeout)) +func (manual *ManualCapability) awaitUserInput(channel chan manualModel.InteractionResponse, ctx context.Context) (cacao.Variables, error) { + for { select { - case <-timer.C: - err := errors.New("manual response timeout, user responded not in time") + case <-ctx.Done(): + err := errors.New("manual response timed-out, no response received on time") log.Error(err) return cacao.NewVariables(), err - case response := <-manual.channel: + case response := <-channel: log.Trace("received response from api") - return response.Variables, response.ResponseError + cacaoVars := response.OutArgsVariables + return cacaoVars, response.ResponseError } } diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index 72608ffd..038fe644 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -2,51 +2,71 @@ package manual import ( "soarca/pkg/core/capability" - "soarca/pkg/interaction" + "soarca/pkg/models/cacao" "soarca/pkg/models/execution" + manualModel "soarca/pkg/models/manual" "soarca/test/unittest/mocks/mock_interaction" + "sync" "testing" "time" "github.com/go-playground/assert/v2" + "github.com/stretchr/testify/mock" ) -func returnQueueCall(channel chan interaction.InteractionResponse) { - - time.Sleep(time.Millisecond * 10) - response := interaction.InteractionResponse{} - channel <- response -} - func TestManualExecution(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) - manual := New(&interactionMock, channel) + var capturedComm manualModel.ManualCapabilityCommunication + + manual := New(&interactionMock) meta := execution.Metadata{} - context := capability.Context{} + commandContext := capability.Context{} + + command := manualModel.CommandInfo{ + Metadata: execution.Metadata{}, + Context: capability.Context{}, + OutArgsVariables: cacao.NewVariables(), + } + + // Capture the channel passed to Queue + + interactionMock.On("Queue", command, mock_interaction.AnyManualCapabilityCommunication()).Return(nil).Run(func(args mock.Arguments) { + capturedComm = args.Get(1).(manualModel.ManualCapabilityCommunication) + }) + + // Use a WaitGroup to wait for the Execute method to complete + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + vars, err := manual.Execute(meta, commandContext) + assert.Equal(t, err, nil) + assert.NotEqual(t, vars, nil) + }() + + // Simulate the response after ensuring the channel is captured + time.Sleep(100 * time.Millisecond) + capturedComm.Channel <- manualModel.InteractionResponse{ + OutArgsVariables: cacao.NewVariables(), + } - command := interaction.InteractionCommand{} - go returnQueueCall(channel) - interactionMock.On("Queue", command, channel).Return(nil) - vars, err := manual.Execute(meta, context) - assert.Equal(t, err, nil) - assert.NotEqual(t, vars, nil) + // Wait for the Execute method to complete + wg.Wait() } func TestTimetoutCalculationNotSet(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) - manual := New(&interactionMock, channel) + manual := New(&interactionMock) timeout := manual.getTimeoutValue(0) assert.Equal(t, timeout, time.Minute) } func TestTimetoutCalculation(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) - manual := New(&interactionMock, channel) + manual := New(&interactionMock) timeout := manual.getTimeoutValue(1) assert.Equal(t, timeout, time.Millisecond*1) } diff --git a/pkg/interaction/interaction.go b/pkg/interaction/interaction.go deleted file mode 100644 index 25165c2d..00000000 --- a/pkg/interaction/interaction.go +++ /dev/null @@ -1,21 +0,0 @@ -package interaction - -import ( - "soarca/pkg/core/capability" - "soarca/pkg/models/cacao" - "soarca/pkg/models/execution" -) - -type InteractionCommand struct { - Metadata execution.Metadata - Context capability.Context -} - -type InteractionResponse struct { - ResponseError error - Variables cacao.Variables -} - -type ICapabilityInteraction interface { - Queue(InteractionCommand, chan InteractionResponse) error -} diff --git a/pkg/models/api/manual.go b/pkg/models/api/manual.go new file mode 100644 index 00000000..35b3acd4 --- /dev/null +++ b/pkg/models/api/manual.go @@ -0,0 +1,31 @@ +package api + +import ( + "soarca/pkg/models/cacao" + "soarca/pkg/models/manual" +) + +// Object interfaced to users storing info about pending manual commands +// TODO: change to manualcommandinfo +type InteractionCommandData struct { + Type string `bson:"type" json:"type" validate:"required" example:"execution-status"` // The type of this content + ExecutionId string `bson:"execution_id" json:"execution_id" validate:"required"` // The id of the execution + PlaybookId string `bson:"playbook_id" json:"playbook_id" validate:"required"` // The id of the CACAO playbook executed by the execution + StepId string `bson:"step_id" json:"step_id" validate:"required"` // The id of the step executed by the execution + Description string `bson:"description" json:"description" validate:"required"` // The description from the workflow step + Command string `bson:"command" json:"command" validate:"required"` // The command for the agent either command + CommandIsBase64 bool `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // Indicates if the command is in b64 + Target cacao.AgentTarget `bson:"target" json:"target" validate:"required"` // Map of cacao agent-target with the target(s) of this command + OutVariables cacao.Variables `bson:"out_args" json:"out_args" validate:"required"` // Map of cacao variables handled in the step out args with current values and definitions +} + +// The object posted on the manual API Continue() payload +type ManualOutArgsUpdatePayload struct { + Type string `bson:"type" json:"type" validate:"required" example:"string"` // The type of this content + ExecutionId string `bson:"execution_id" json:"execution_id" validate:"required"` // The id of the execution + PlaybookId string `bson:"playbook_id" json:"playbook_id" validate:"required"` // The id of the CACAO playbook executed by the execution + StepId string `bson:"step_id" json:"step_id" validate:"required"` // The id of the step executed by the execution + ResponseStatus manual.ManualResponseStatus `bson:"response_status" json:"response_status" validate:"required"` // Indicates status of command + + ResponseOutArgs cacao.Variables `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables storing the out args value, handled in the step out args, with current values and definitions +} diff --git a/pkg/models/cacao/cacao.go b/pkg/models/cacao/cacao.go index 74b781c4..fa357fbd 100644 --- a/pkg/models/cacao/cacao.go +++ b/pkg/models/cacao/cacao.go @@ -66,7 +66,7 @@ type ( // CACAO Variable type Variable struct { Type string `bson:"type" json:"type" validate:"required" example:"string"` // Type of the variable should be OASIS variable-type-ov - Name string `bson:"name,omitempty" json:"name,omitempty" example:"__example_string__"` // The name of the variable in the style __variable_name__ + Name string `bson:"name,omitempty" json:"name,omitempty" example:"__example_string__"` // The name of the variable in the style __variable_name__ (included in object for utility) Description string `bson:"description,omitempty" json:"description,omitempty" example:"some string"` // A description of the variable Value string `bson:"value,omitempty" json:"value,omitempty" example:"this is a value"` // The value of the that the variable will evaluate to Constant bool `bson:"constant,omitempty" json:"constant,omitempty" example:"false"` // Indicate if it's a constant @@ -207,6 +207,8 @@ type Command struct { ContentB64 string `bson:"content_b64,omitempty" json:"content_b64,omitempty"` } +type OutArgs []string + type Step struct { Type string `bson:"type" json:"type" validate:"required"` ID string `bson:"id,omitempty" json:"id,omitempty"` @@ -224,7 +226,7 @@ type Step struct { Agent string `bson:"agent,omitempty" json:"agent,omitempty"` Targets []string `bson:"targets,omitempty" json:"targets,omitempty"` InArgs []string `bson:"in_args,omitempty" json:"in_args,omitempty"` - OutArgs []string `bson:"out_args,omitempty" json:"out_args,omitempty"` + OutArgs OutArgs `bson:"out_args,omitempty" json:"out_args,omitempty"` PlaybookID string `bson:"playbook_id,omitempty" json:"playbook_id,omitempty"` PlaybookVersion string `bson:"playbook_version,omitempty" json:"playbook_version,omitempty"` NextSteps []string `bson:"next_steps,omitempty" json:"next_steps,omitempty"` diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go new file mode 100644 index 00000000..6fa387f6 --- /dev/null +++ b/pkg/models/manual/manual.go @@ -0,0 +1,68 @@ +package manual + +import ( + "context" + "soarca/pkg/core/capability" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" +) + +// ################################################################################ +// Data structures for native SOARCA manual command handling +// ################################################################################ + +type ManualResponseStatus string + +const ( + Success ManualResponseStatus = "success" + Failure ManualResponseStatus = "failure" +) + +type InteractionStorageEntry struct { + CommandInfo CommandInfo + Channel chan InteractionResponse +} + +// Object passed by the manual capability to the Interaction module +type CommandInfo struct { + Metadata execution.Metadata + Context capability.Context + OutArgsVariables cacao.Variables +} + +// Deep copy for the command that the Interaction module notifies to the integrations +type InteractionIntegrationCommand struct { + Metadata execution.Metadata + Context capability.Context + OutArgsVariables cacao.Variables +} + +// Object returned to the Interaction object in fulfilment of a manual command +type InteractionResponse struct { + Metadata execution.Metadata + ResponseStatus ManualResponseStatus + ResponseError error + OutArgsVariables cacao.Variables +} + +type ManualCapabilityCommunication struct { + TimeoutContext context.Context + Channel chan InteractionResponse +} + +// Errors ##################################################################### +type ErrorPendingCommandNotFound struct { + Err string +} + +type ErrorNonMatchingOutArgs struct { + Err string +} + +func (e ErrorPendingCommandNotFound) Error() string { + return e.Err +} + +func (e ErrorNonMatchingOutArgs) Error() string { + return e.Err +} diff --git a/pkg/models/utils/context/context.go b/pkg/models/utils/context/context.go new file mode 100644 index 00000000..a8503bd2 --- /dev/null +++ b/pkg/models/utils/context/context.go @@ -0,0 +1,4 @@ +package context + +const ErrorContextCanceled string = "context canceled" +const ErrorContextTimeout string = "context deadline exceeded" diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go new file mode 100644 index 00000000..16f8b55c --- /dev/null +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -0,0 +1,204 @@ +package manual_api_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + api_routes "soarca/pkg/api" + manual_api "soarca/pkg/api/manual" + apiModel "soarca/pkg/models/api" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" + "soarca/test/unittest/mocks/mock_interaction_storage" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-playground/assert/v2" + "github.com/google/uuid" +) + +func TestGetPendingCommandsCalled(t *testing.T) { + mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} + manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) + + app := gin.New() + gin.SetMode(gin.DebugMode) + + recorder := httptest.NewRecorder() + api_routes.ManualRoutes(app, manualApiHandler) + + mock_interaction_storage.On("GetPendingCommands").Return([]manual.CommandInfo{}, nil) + + request, err := http.NewRequest("GET", "/manual/", nil) + if err != nil { + t.Fail() + } + + app.ServeHTTP(recorder, request) + expectedString := "[]" + + assert.Equal(t, expectedString, recorder.Body.String()) + assert.Equal(t, 200, recorder.Code) + + mock_interaction_storage.AssertExpectations(t) +} + +func TestGetPendingCommandCalled(t *testing.T) { + mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} + manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) + + app := gin.New() + gin.SetMode(gin.DebugMode) + + recorder := httptest.NewRecorder() + api_routes.ManualRoutes(app, manualApiHandler) + testExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testStepId := "61a4d52c-6efc-4516-a242-dfbc5c89d312" + path := "/manual/" + testExecId + "/" + testStepId + executionMetadata := execution.Metadata{ + ExecutionId: uuid.MustParse(testExecId), StepId: testStepId, + } + + testEmptyResponsePendingCommand := apiModel.InteractionCommandData{ + Type: "manual-command-info", + ExecutionId: "00000000-0000-0000-0000-000000000000", + } + emptyCommandInfoList := manual.CommandInfo{} + + mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(emptyCommandInfoList, nil) + + request, err := http.NewRequest("GET", path, nil) + if err != nil { + t.Fail() + } + + app.ServeHTTP(recorder, request) + + expectedJSON, err := json.Marshal(testEmptyResponsePendingCommand) + if err != nil { + t.Fatalf("Error marshalling expected JSON: %v", err) + } + t.Log("response:") + t.Log(recorder.Body.String()) + + expectedString := string(expectedJSON) + assert.Equal(t, expectedString, recorder.Body.String()) + assert.Equal(t, 200, recorder.Code) + + mock_interaction_storage.AssertExpectations(t) +} + +func TestPostContinueCalled(t *testing.T) { + mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} + manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) + + app := gin.New() + gin.SetMode(gin.DebugMode) + + recorder := httptest.NewRecorder() + api_routes.ManualRoutes(app, manualApiHandler) + testExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testStepId := "61a4d52c-6efc-4516-a242-dfbc5c89d312" + testPlaybookId := "21a4d52c-6efc-4516-a242-dfbc5c89d312" + path := "/manual/continue" + + testManualUpdatePayload := apiModel.ManualOutArgsUpdatePayload{ + Type: "manual-step-response", + ExecutionId: testExecId, + StepId: testStepId, + PlaybookId: testPlaybookId, + ResponseStatus: "success", + ResponseOutArgs: cacao.Variables{ + "testvar": { + Type: "string", + Name: "testvar", + Value: "testing!", + }, + }, + } + + testManualResponse := manual.InteractionResponse{ + Metadata: execution.Metadata{ + ExecutionId: uuid.MustParse(testExecId), + StepId: testStepId, + PlaybookId: testPlaybookId, + }, + ResponseStatus: "success", + OutArgsVariables: cacao.Variables{ + "testvar": { + Type: "string", + Name: "testvar", + Value: "testing!", + }, + }, + } + jsonData, err := json.Marshal(testManualUpdatePayload) + if err != nil { + t.Fatalf("Error marshalling JSON: %v", err) + } + + mock_interaction_storage.On("PostContinue", testManualResponse).Return(nil) + + request, err := http.NewRequest("POST", path, bytes.NewBuffer(jsonData)) + if err != nil { + t.Fail() + } + + app.ServeHTTP(recorder, request) + t.Log(recorder.Body.String()) + assert.Equal(t, 200, recorder.Code) + + mock_interaction_storage.AssertExpectations(t) +} + +func TestPostContinueFailsOnNonMatchingOutArgNames(t *testing.T) { + mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} + manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) + + app := gin.New() + gin.SetMode(gin.DebugMode) + + recorder := httptest.NewRecorder() + api_routes.ManualRoutes(app, manualApiHandler) + testExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testStepId := "61a4d52c-6efc-4516-a242-dfbc5c89d312" + testPlaybookId := "21a4d52c-6efc-4516-a242-dfbc5c89d312" + path := "/manual/continue" + + testManualUpdatePayload := apiModel.ManualOutArgsUpdatePayload{ + Type: "manual-step-response", + ExecutionId: testExecId, + StepId: testStepId, + PlaybookId: testPlaybookId, + ResponseStatus: "success", + ResponseOutArgs: cacao.Variables{ + "__this_var__": { + Type: "string", + Name: "__is_invalid__", + Value: "testing!", + }, + }, + } + + manualUpdatePayloadJson, err := json.Marshal(testManualUpdatePayload) + if err != nil { + t.Fatalf("Error marshalling JSON: %v", err) + } + + request, err := http.NewRequest("POST", path, bytes.NewBuffer(manualUpdatePayloadJson)) + if err != nil { + t.Fail() + } + + expectedErr := errors.New("Variable name mismatch") + + app.ServeHTTP(recorder, request) + t.Log(recorder.Body.String()) + assert.Equal(t, 400, recorder.Code) + assert.Equal(t, true, strings.Contains(recorder.Body.String(), expectedErr.Error())) + +} diff --git a/test/manual/capability/capability_controller_test.go b/test/manual/capability/capability_controller_test.go index a93ca0b4..c3a12f8d 100644 --- a/test/manual/capability/capability_controller_test.go +++ b/test/manual/capability/capability_controller_test.go @@ -2,7 +2,7 @@ package capability_controller_test import ( "fmt" - "soarca/pkg/core/capability/controller" + "soarca/pkg/core/capability/fin/controller" "testing" mqtt "github.com/eclipse/paho.mqtt.golang" diff --git a/test/unittest/mocks/mock_interaction/mock_interaction.go b/test/unittest/mocks/mock_interaction/mock_interaction.go index 3a518029..6d840ad3 100644 --- a/test/unittest/mocks/mock_interaction/mock_interaction.go +++ b/test/unittest/mocks/mock_interaction/mock_interaction.go @@ -1,7 +1,8 @@ package mock_interaction import ( - "soarca/pkg/interaction" + "context" + "soarca/pkg/models/manual" "github.com/stretchr/testify/mock" ) @@ -10,8 +11,29 @@ type MockInteraction struct { mock.Mock } -func (mock *MockInteraction) Queue(command interaction.InteractionCommand, - channel chan interaction.InteractionResponse) error { - args := mock.Called(command, channel) +func (mock *MockInteraction) Queue(command manual.CommandInfo, + manualComms manual.ManualCapabilityCommunication) error { + args := mock.Called(command, manualComms) return args.Error(0) } + +// Custom matcher for context that always returns true +func AnyContext() interface{} { + return mock.MatchedBy(func(ctx context.Context) bool { + return true + }) +} + +// Custom matcher to capture the channel +func AnyChannel() interface{} { + return mock.MatchedBy(func(ch chan manual.InteractionResponse) bool { + return true + }) +} + +// Custom matcher for any ManualCapabilityCommunication +func AnyManualCapabilityCommunication() interface{} { + return mock.MatchedBy(func(comm manual.ManualCapabilityCommunication) bool { + return true + }) +} diff --git a/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go new file mode 100644 index 00000000..65ccaaef --- /dev/null +++ b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go @@ -0,0 +1,27 @@ +package mock_interaction_storage + +import ( + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" + + "github.com/stretchr/testify/mock" +) + +type MockInteractionStorage struct { + mock.Mock +} + +func (mock *MockInteractionStorage) GetPendingCommands() ([]manual.CommandInfo, error) { + args := mock.Called() + return args.Get(0).([]manual.CommandInfo), args.Error(1) +} + +func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) { + args := mock.Called(metadata) + return args.Get(0).(manual.CommandInfo), args.Error(1) +} + +func (mock *MockInteractionStorage) PostContinue(response manual.InteractionResponse) error { + args := mock.Called(response) + return args.Error(0) +}