From 8a04d2c6e24721f59312d715dde1d216b1b1b4e5 Mon Sep 17 00:00:00 2001 From: "luca.morgese@tno.nl" Date: Wed, 4 Dec 2024 13:36:40 +0100 Subject: [PATCH 01/63] restructured capability controllers --- internal/controller/controller.go | 2 +- pkg/api/manual/manual_endpoints.go | 40 +++++++++++++++++++ .../{ => fin}/controller/controller.go | 0 .../{ => fin}/controller/controller_test.go | 0 .../manual/controller/controller.go | 31 ++++++++++++++ .../manual/controller/controller_test.go | 9 +++++ 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 pkg/api/manual/manual_endpoints.go rename pkg/core/capability/{ => fin}/controller/controller.go (100%) rename pkg/core/capability/{ => fin}/controller/controller_test.go (100%) create mode 100644 pkg/core/capability/manual/controller/controller.go create mode 100644 pkg/core/capability/manual/controller/controller_test.go diff --git a/internal/controller/controller.go b/internal/controller/controller.go index ddd0a755..22f8d060 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -8,6 +8,7 @@ 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/openc2" @@ -24,7 +25,6 @@ import ( "strconv" "strings" - capabilityController "soarca/pkg/core/capability/controller" finExecutor "soarca/pkg/core/capability/fin" thehive "soarca/pkg/integration/thehive/reporter" diff --git a/pkg/api/manual/manual_endpoints.go b/pkg/api/manual/manual_endpoints.go new file mode 100644 index 00000000..422cd303 --- /dev/null +++ b/pkg/api/manual/manual_endpoints.go @@ -0,0 +1,40 @@ +package manual + +import ( + "soarca/pkg/core/capability/manual/controller" + + "github.com/gin-gonic/gin" +) + +// TODO: +// The manual API expose general executions-wide information +// Thus, we need a ManualController that implements an IManualController interface +// The API routes will invoke the IManualController interface instance (the ManualController) +// In turn, the manual capability itself will implement, besides the execution capability, +// also some IManualInteraction interface, which will expose and consume information specific +// to a manual interaction (the function that the ManualController will invoke on the manual capability, +// to GET manual/execution-id/step-id, and POST manual/continue). + +// A manual command in CACAO is simply the operation: +// { post_message; wait_for_response (returning a result) } +// +// Agent and target for the command itself make little sense. +// Unless an agent is the intended system that does post_message, and wait_for_response. +// But the targets? For the automated execution, there is no need to specify any. +// +// 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. + +// The controller manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) + +func Routes(route *gin.Engine, manualController *controller.IManualController) { + // group := route.Group("/manual") + // { + // group.GET("/", manualController.GetPendingAll()) + // group.GET("/:executionId/:stepId", manualController.GetPendingCommand) + // group.POST("/manual/continue", manualController.PostContinue) + // } +} 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/controller/controller.go b/pkg/core/capability/manual/controller/controller.go new file mode 100644 index 00000000..92905447 --- /dev/null +++ b/pkg/core/capability/manual/controller/controller.go @@ -0,0 +1,31 @@ +package controller + +import ( + "reflect" + "soarca/internal/logger" +) + +type Empty struct{} + +var component = reflect.TypeOf(Empty{}).PkgPath() +var log *logger.Log + +func init() { + log = logger.Logger(component, logger.Info, "", logger.Json) +} + +type ManualCommandInfo struct { + Name string + Id string + FinId string +} + +type IManualController interface { + GetPendingCommands() map[string]ManualCommandInfo + GetPendingCommand() map[string]ManualCommandInfo + PostContinue() map[string]ManualCommandInfo +} + +type ManualController struct { + manualCommandsRegistry map[string]ManualCommandInfo +} diff --git a/pkg/core/capability/manual/controller/controller_test.go b/pkg/core/capability/manual/controller/controller_test.go new file mode 100644 index 00000000..567cb7ac --- /dev/null +++ b/pkg/core/capability/manual/controller/controller_test.go @@ -0,0 +1,9 @@ +package controller + +import ( + "testing" +) + +func TestHello(t *testing.T) { + +} From a3e4be0bd6d3b0d019f44d85dfac7ca0885dd0d1 Mon Sep 17 00:00:00 2001 From: "luca.morgese@tno.nl" Date: Wed, 4 Dec 2024 13:40:28 +0100 Subject: [PATCH 02/63] fix test import --- test/manual/capability/capability_controller_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 423c4810c2242ccabb74662626ad343086c1d52d Mon Sep 17 00:00:00 2001 From: "luca.morgese@tno.nl" Date: Wed, 4 Dec 2024 15:18:32 +0100 Subject: [PATCH 03/63] preset manual api components --- pkg/api/manual/manual_api.go | 62 +++++++++++++++++++ pkg/api/manual/manual_endpoints.go | 16 +++-- .../manual/controller/controller.go | 2 + pkg/models/manual/manual.go | 33 ++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 pkg/api/manual/manual_api.go create mode 100644 pkg/models/manual/manual.go diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go new file mode 100644 index 00000000..9ae9c775 --- /dev/null +++ b/pkg/api/manual/manual_api.go @@ -0,0 +1,62 @@ +package manual + +import ( + "github.com/gin-gonic/gin" +) + +type ManualHandler struct { + // +} + +// 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.Error +// @Router /manual/ [GET] +func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { + +} + +// 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 execution_id path string true "execution ID" +// @Param step_id path string true "step ID" +// @Success 200 {object} api.Execution +// @failure 400 {object} api.Error +// @Router /manual/{execution_id}/{step_id} [GET] +func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { + +} + +// 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 type body string true "type" +// @Param execution_id body string true "execution 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 model.ResponseOutArgs true "out args" +// @Success 200 {object} api.Execution +// @failure 400 {object} api.Error +// @Router /manual/continue/ [POST] +func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { + +} diff --git a/pkg/api/manual/manual_endpoints.go b/pkg/api/manual/manual_endpoints.go index 422cd303..11748df4 100644 --- a/pkg/api/manual/manual_endpoints.go +++ b/pkg/api/manual/manual_endpoints.go @@ -1,8 +1,6 @@ package manual import ( - "soarca/pkg/core/capability/manual/controller" - "github.com/gin-gonic/gin" ) @@ -30,11 +28,11 @@ import ( // The controller manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) -func Routes(route *gin.Engine, manualController *controller.IManualController) { - // group := route.Group("/manual") - // { - // group.GET("/", manualController.GetPendingAll()) - // group.GET("/:executionId/:stepId", manualController.GetPendingCommand) - // group.POST("/manual/continue", manualController.PostContinue) - // } +func Routes(route *gin.Engine, manualHandler *ManualHandler) { + group := route.Group("/manual") + { + group.GET("/", manualHandler.GetPendingCommands) + group.GET("/:executionId/:stepId", manualHandler.GetPendingCommand) + group.POST("/manual/continue", manualHandler.PostContinue) + } } diff --git a/pkg/core/capability/manual/controller/controller.go b/pkg/core/capability/manual/controller/controller.go index 92905447..c5b40cb9 100644 --- a/pkg/core/capability/manual/controller/controller.go +++ b/pkg/core/capability/manual/controller/controller.go @@ -29,3 +29,5 @@ type IManualController interface { type ManualController struct { manualCommandsRegistry map[string]ManualCommandInfo } + +func (controller *ManualController) GetPendingCommands() diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go new file mode 100644 index 00000000..e14ad8f9 --- /dev/null +++ b/pkg/models/manual/manual.go @@ -0,0 +1,33 @@ +package manual + +import "soarca/pkg/models/cacao" + +type ManualCommandData 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:"command_is_base64" json:"command_is_base64" validate:"required"` // Indicate the command is in base 64 + Targets map[string]cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command + OutArgs 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 +} + +type ManualOutArg struct { + Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ + Value string `bson:"value" json:"value" validate:"required" example:"this is a value"` // The value of the that the variable will evaluate to + Type string `bson:"type,omitempty" json:"type,omitempty" example:"string"` // Type of the variable should be OASIS variable-type-ov + Description string `bson:"description,omitempty" json:"description,omitempty" example:"some string"` // A description of the variable + Constant bool `bson:"constant,omitempty" json:"constant,omitempty" example:"false"` // Indicate if it's a constant + External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external +} + +type ManualRequestPayload 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 bool `bson:"response_status" json:"response_status" validate:"required"` // Can be either success or failure + ResponseOutArgs map[string]ManualOutArg `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions +} From f673984d2e1b1c605cd7d3bf369bb8f4f49a7136 Mon Sep 17 00:00:00 2001 From: "luca.morgese@tno.nl" Date: Wed, 4 Dec 2024 15:21:09 +0100 Subject: [PATCH 04/63] update documentation to match data structs --- docs/content/en/docs/core-components/api-manual.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 52888645..5ada37e5 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -154,7 +154,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 | @@ -164,8 +164,7 @@ Respond to manual command pending in SOARCA, if out_args are defined they must b |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_out_args |cacao variables name and value |dictionary |Map of cacao variables's names and values, as per variables handled in the step out args ```plantuml From 0cb3c54bd7ad7cce8b703891f5e26d366a4e9c1f Mon Sep 17 00:00:00 2001 From: "luca.morgese@tno.nl" Date: Wed, 4 Dec 2024 16:37:27 +0100 Subject: [PATCH 05/63] define first interfaces for manual interaction mechanisms --- pkg/api/manual/manual_api.go | 1 - .../manual/controller/controller.go | 33 ---------- .../capability/manual/interaction/api/api.go | 1 + .../manual/interaction/api/api_test.go | 1 + .../manual/interaction/interaction.go | 62 +++++++++++++++++++ .../interaction_test.go} | 2 +- pkg/models/manual/manual.go | 6 +- 7 files changed, 69 insertions(+), 37 deletions(-) delete mode 100644 pkg/core/capability/manual/controller/controller.go create mode 100644 pkg/core/capability/manual/interaction/api/api.go create mode 100644 pkg/core/capability/manual/interaction/api/api_test.go create mode 100644 pkg/core/capability/manual/interaction/interaction.go rename pkg/core/capability/manual/{controller/controller_test.go => interaction/interaction_test.go} (74%) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 9ae9c775..a1c139c6 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -5,7 +5,6 @@ import ( ) type ManualHandler struct { - // } // manual diff --git a/pkg/core/capability/manual/controller/controller.go b/pkg/core/capability/manual/controller/controller.go deleted file mode 100644 index c5b40cb9..00000000 --- a/pkg/core/capability/manual/controller/controller.go +++ /dev/null @@ -1,33 +0,0 @@ -package controller - -import ( - "reflect" - "soarca/internal/logger" -) - -type Empty struct{} - -var component = reflect.TypeOf(Empty{}).PkgPath() -var log *logger.Log - -func init() { - log = logger.Logger(component, logger.Info, "", logger.Json) -} - -type ManualCommandInfo struct { - Name string - Id string - FinId string -} - -type IManualController interface { - GetPendingCommands() map[string]ManualCommandInfo - GetPendingCommand() map[string]ManualCommandInfo - PostContinue() map[string]ManualCommandInfo -} - -type ManualController struct { - manualCommandsRegistry map[string]ManualCommandInfo -} - -func (controller *ManualController) GetPendingCommands() diff --git a/pkg/core/capability/manual/interaction/api/api.go b/pkg/core/capability/manual/interaction/api/api.go new file mode 100644 index 00000000..778f64ec --- /dev/null +++ b/pkg/core/capability/manual/interaction/api/api.go @@ -0,0 +1 @@ +package api diff --git a/pkg/core/capability/manual/interaction/api/api_test.go b/pkg/core/capability/manual/interaction/api/api_test.go new file mode 100644 index 00000000..778f64ec --- /dev/null +++ b/pkg/core/capability/manual/interaction/api/api_test.go @@ -0,0 +1 @@ +package api diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go new file mode 100644 index 00000000..d95e577b --- /dev/null +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -0,0 +1,62 @@ +package interaction + +import ( + "reflect" + "soarca/internal/logger" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" +) + +type Empty struct{} + +var component = reflect.TypeOf(Empty{}).PkgPath() +var log *logger.Log + +func init() { + log = logger.Logger(component, logger.Info, "", logger.Json) +} + +// NOTE: +// The InteractionController is injected with all configured Interactions (SOARCA API always, plus AT MOST ONE integration) +// The manual capability is injected with the InteractionController +// The manual capability triggers interactioncontroller.PostCommand +// The InteractionController register a manual command pending in its memory registry +// The manual capability waits on interactioncontroller.WasCompleted() status != pending (to implement) +// Meanwhile, external systems use the InteractionController to do GetPending. GetPending just uses the memory registry of InteractionController +// Also meanwhile, external systems can use InteractionController to do Continue() +// Upon a Continue and relative updates, the WasCompleted will return status == completed, and the related info +// The manual capability continues. + +type IInteraction interface { + PostCommand(inquiry manual.ManualCommandData) error + Continue(outArgsResult manual.ManualOutArgUpdatePayload) error +} + +type IInteractionController interface { + PostCommand(inquiry manual.ManualCommandData) error + GetPendingCommands() ([]manual.ManualCommandData, error) + GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) + Continue(outArgsResult manual.ManualOutArgUpdatePayload) error + WasCompleted(metadata execution.Metadata) (cacao.Variables, string, error) +} + +type InteractionController struct { + PendingCommands map[string]manual.ManualCommandData // Keyed on execution ID + Interactions []IInteraction +} + +func (manualController *InteractionController) GetPendingCommands() ([]manual.ManualCommandData, error) { + log.Trace("getting pending manual commands") + return []manual.ManualCommandData{}, nil +} + +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) { + log.Trace("getting pending manual command") + return manual.ManualCommandData{}, nil +} + +func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) error { + log.Trace("completing manual command") + return nil +} diff --git a/pkg/core/capability/manual/controller/controller_test.go b/pkg/core/capability/manual/interaction/interaction_test.go similarity index 74% rename from pkg/core/capability/manual/controller/controller_test.go rename to pkg/core/capability/manual/interaction/interaction_test.go index 567cb7ac..557ceaed 100644 --- a/pkg/core/capability/manual/controller/controller_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -1,4 +1,4 @@ -package controller +package interaction import ( "testing" diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index e14ad8f9..90688a05 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -1,6 +1,8 @@ package manual -import "soarca/pkg/models/cacao" +import ( + "soarca/pkg/models/cacao" +) type ManualCommandData struct { Type string `bson:"type" json:"type" validate:"required" example:"execution-status"` // The type of this content @@ -23,7 +25,7 @@ type ManualOutArg struct { External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external } -type ManualRequestPayload struct { +type ManualOutArgUpdatePayload 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 From ba9bfe43b171e626d67d9065848a2e49b7795143 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:01:36 +0100 Subject: [PATCH 06/63] fix lint --- pkg/core/capability/manual/interaction/interaction.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index d95e577b..c694b397 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -25,7 +25,7 @@ func init() { // The manual capability waits on interactioncontroller.WasCompleted() status != pending (to implement) // Meanwhile, external systems use the InteractionController to do GetPending. GetPending just uses the memory registry of InteractionController // Also meanwhile, external systems can use InteractionController to do Continue() -// Upon a Continue and relative updates, the WasCompleted will return status == completed, and the related info +// Upon a Continue and relative updates, the IsCompleted will return status == completed, and the related info // The manual capability continues. type IInteraction interface { @@ -38,7 +38,7 @@ type IInteractionController interface { GetPendingCommands() ([]manual.ManualCommandData, error) GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) Continue(outArgsResult manual.ManualOutArgUpdatePayload) error - WasCompleted(metadata execution.Metadata) (cacao.Variables, string, error) + IsCompleted(metadata execution.Metadata) (cacao.Variables, string, error) } type InteractionController struct { From e7acab5ebc3ce7d5d936f4416cd5a72dce75cd46 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:09:08 +0100 Subject: [PATCH 07/63] fix lint --- pkg/api/manual/manual_api.go | 7 +++++-- pkg/api/manual/manual_endpoints.go | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index a1c139c6..05de8e87 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -1,6 +1,8 @@ package manual import ( + "soarca/pkg/models/manual" + "github.com/gin-gonic/gin" ) @@ -52,10 +54,11 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @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 model.ResponseOutArgs true "out args" +// @Param response_out_args body manual.ManualOutArg true "out args" // @Success 200 {object} api.Execution // @failure 400 {object} api.Error // @Router /manual/continue/ [POST] func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { - + var test manual.ManualOutArg + log.Debug(test) } diff --git a/pkg/api/manual/manual_endpoints.go b/pkg/api/manual/manual_endpoints.go index 11748df4..16d44e96 100644 --- a/pkg/api/manual/manual_endpoints.go +++ b/pkg/api/manual/manual_endpoints.go @@ -1,9 +1,20 @@ package manual import ( + "reflect" + "soarca/internal/logger" + "github.com/gin-gonic/gin" ) +var log *logger.Log + +type Empty struct{} + +func init() { + log = logger.Logger(reflect.TypeOf(Empty{}).PkgPath(), logger.Info, "", logger.Json) +} + // TODO: // The manual API expose general executions-wide information // Thus, we need a ManualController that implements an IManualController interface From e7b53b1376bb9e9c4e3f04a73c950dde638a942c Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:31:33 +0100 Subject: [PATCH 08/63] update manual docs --- .../en/docs/core-components/api-manual.md | 2 +- .../en/docs/core-components/modules.md | 66 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 5ada37e5..6800056b 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -15,7 +15,7 @@ We will use HTTP status codes https://en.wikipedia.org/wiki/List_of_HTTP_status_ ```plantuml @startuml -protocol Reporter { +protocol Manual { GET /manual POST /manual/continue } diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 8d0ae4f9..a84ee4fa 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -325,9 +325,71 @@ This example will start an operation that executes the ability with ID `36eecb80 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). - From e64540cfa179716d7b255581a7a8e602696ba190 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:45:11 +0100 Subject: [PATCH 09/63] update architecture and related docs --- .../en/docs/core-components/modules.md | 88 ++++++++++++------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index a84ee4fa..aab26d65 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -342,58 +342,86 @@ Because the *somewhere* and *somehow* for posting a message can vary, and the *s @startuml set separator :: -interface ICapability{ - Execute() -} - -interface IInteractionController{ - PostCommand() - GetPendingCommands() - GetPendingCommand() - Continue() - IsCompleted() -} +class ManualStep protocol ManualAPI { GET /manual POST /manual/continue } -interface IInteraction { - PostCommand() - Continue() +interface ICapability{ + Execute() } -class ManualStep +interface ICapabilityInteraction{ + Queue(command InteractionCommand, channel chan InteractionResponse) +} -class InteractionController { - interactions []IInteraction +interface IInteracionStorage{ + GetPendingCommands() + GetPendingCommand() + Continue() } -class Interaction +interface IInteractionIntegrationNotifier { + Notify(command InteractionIntegrationCommand, channel chan IntegrationInteractionResponse) +} -ManualStep .left.|> ICapability -ManualStep -right-> IInteractionController -InteractionController .up.|> IInteractionController +class Interaction { + notifiers []IInteractionIntegrationNotifier +} +class ThirdPartyManualIntegration -ManualAPI -left-> IInteractionController -InteractionController -right-> IInteraction -Interaction .up.|> IInteraction +ManualStep .up.|> ICapability +ManualStep -down-> ICapabilityInteraction +Interaction .up.|> ICapabilityInteraction +Interaction .up.|> IInteracionStorage +ManualAPI -down-> IInteracionStorage -``` +Interaction -right-> IInteractionIntegrationNotifier +ThirdPartyManualIntegration .up.|> IInteractionIntegrationNotifier -The main way to interact with the manual step is through SOARCA's [manual api](/docs/core-components/api-manual). +``` +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. +Integration's code should implement the *IInteractionIntegrationNotifier* interface, returning the result of the manual command execution in form of an `IntegrationInteractionResponse` object, into the respective channel. +The diagram below displays the way the manual interactions components work. - - - - +```plantuml +@startuml +control "ManualStep" as manual +control "Interaction" as interaction +control "ManualAPI" as api +control "ThirdPartyManualIntegration" as 3ptool + + +manual -> interaction : Queue(command, channel) +interaction -> interaction : save manual command status +interaction -> 3ptool : Notify(interactionCommand, interactionChannel) +activate interaction +interaction -> interaction : idle wait on chan + +alt +3ptool <--> Integration : command posting and handling +3ptool -> 3ptool : post IntegrationInteractionResponse on channel +3ptool --> interaction +end +alt +api -> interaction : GetPendingCommands() +api -> interaction : GetPendingCommand(executionId, stepId) +api -> interaction : Continue(InteractionResponse) +end + +deactivate interaction +interaction -> manual : manual command results + +@enduml +``` #### Success and failure From 67a58647858dc56b5599539f5f239ab131382c21 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:00:23 +0100 Subject: [PATCH 10/63] update manual flows description --- docs/content/en/docs/core-components/modules.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index aab26d65..4c326efa 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -402,16 +402,16 @@ control "ThirdPartyManualIntegration" as 3ptool manual -> interaction : Queue(command, channel) interaction -> interaction : save manual command status -interaction -> 3ptool : Notify(interactionCommand, interactionChannel) +interaction -> 3ptool : async Notify(interactionCommand, interactionChannel) activate interaction interaction -> interaction : idle wait on chan -alt +alt Third Party Integration flow 3ptool <--> Integration : command posting and handling 3ptool -> 3ptool : post IntegrationInteractionResponse on channel 3ptool --> interaction end -alt +alt Native ManualAPI flow api -> interaction : GetPendingCommands() api -> interaction : GetPendingCommand(executionId, stepId) api -> interaction : Continue(InteractionResponse) @@ -423,6 +423,8 @@ interaction -> manual : manual command results @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. + #### 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. From 6b76c3eddb26fd76a9ffd4cd97caf36a102583d3 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:08:19 +0100 Subject: [PATCH 11/63] Updates according to our discussion M&L --- docs/content/en/docs/core-components/modules.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 4c326efa..2d268cea 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -401,24 +401,25 @@ control "ThirdPartyManualIntegration" as 3ptool manual -> interaction : Queue(command, channel) +activate interaction interaction -> interaction : save manual command status +alt Third Party Integration flow interaction -> 3ptool : async Notify(interactionCommand, interactionChannel) -activate interaction +activate 3ptool interaction -> interaction : idle wait on chan -alt Third Party Integration flow 3ptool <--> Integration : command posting and handling 3ptool -> 3ptool : post IntegrationInteractionResponse on channel 3ptool --> interaction -end -alt Native ManualAPI flow +deactivate 3ptool +else Native ManualAPI flow api -> interaction : GetPendingCommands() api -> interaction : GetPendingCommand(executionId, stepId) api -> interaction : Continue(InteractionResponse) end -deactivate interaction interaction -> manual : manual command results +deactivate interaction @enduml ``` From 602db289e9231179167d301e3d1614140fcb576e Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:07:37 +0100 Subject: [PATCH 12/63] progress setup manual api mechanisms --- pkg/api/manual/manual_api.go | 105 ++++++++++++++++-- pkg/api/manual/manual_endpoints.go | 49 -------- .../capability/manual/interaction/api/api.go | 1 - .../manual/interaction/api/api_test.go | 1 - .../manual/interaction/interaction.go | 16 +-- pkg/models/manual/manual.go | 26 ++++- 6 files changed, 125 insertions(+), 73 deletions(-) delete mode 100644 pkg/api/manual/manual_endpoints.go delete mode 100644 pkg/core/capability/manual/interaction/api/api.go delete mode 100644 pkg/core/capability/manual/interaction/api/api_test.go diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 05de8e87..31a76bec 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -1,12 +1,51 @@ package manual import ( + "net/http" + "reflect" + "soarca/internal/logger" + "soarca/pkg/core/capability/manual/interaction" + "soarca/pkg/models/api" + "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 + +// Agent and target for the manual command itself make little sense. +// Unless an agent is the intended system that does post_message, and wait_for_response. +// But the targets? For the automated execution, there is no need to specify any. +// +// 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. + +// The InteractionCapability manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) + +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 } // manual @@ -18,10 +57,20 @@ type ManualHandler struct { // @Accept json // @Produce json // @Success 200 {object} api.Execution -// @failure 400 {object} api.Error +// @failure 400 {object} []manual.ManualCommandData // @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/", err.Error()) + return + } + g.JSON(http.StatusOK, + commands) + return } // manual @@ -34,11 +83,33 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { // @Produce json // @Param execution_id path string true "execution ID" // @Param step_id path string true "step ID" -// @Success 200 {object} api.Execution +// @Success 200 {object} manual.ManualCommandData // @failure 400 {object} api.Error // @Router /manual/{execution_id}/{step_id} [GET] func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { + execution_id := g.Param("execution_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, err.Error()) + return + } + executionMetadata := execution.Metadata{ExecutionId: execId, StepId: step_id} + commandData, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) + if err != nil { + log.Error(err) + apiError.SendErrorResponse(g, http.StatusInternalServerError, + "Failed to provide pending manual command", + "GET /manual/"+execution_id+"/"+step_id, err.Error()) + return + } + g.JSON(http.StatusOK, + commandData) + return } // manual @@ -49,16 +120,34 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @Tags manual // @Accept json // @Produce json -// @Param type body string true "type" -// @Param execution_id body string true "execution 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 manual.ManualOutArg true "out args" +// @Param response_out_args body manual.ManualOutArgs true "out args" // @Success 200 {object} api.Execution // @failure 400 {object} api.Error // @Router /manual/continue/ [POST] func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { - var test manual.ManualOutArg - log.Debug(test) + execution_id := g.Param("execution_id") + playbook_id := g.Param("playbook_id") + step_id := g.Param("step_id") + outArgsUpdate := manual.ManualOutArgUpdatePayload{ + Type: g.Param("type"), + ExecutionId: execution_id, + PlaybookId: playbook_id, + StepId: step_id, + } + err := manualHandler.interactionCapability.Continue(outArgsUpdate) + if err != nil { + log.Error(err) + apiError.SendErrorResponse(g, http.StatusInternalServerError, + "Failed to post continue ID", + "POST /manual/continue", err.Error()) + return + } + g.JSON(http.StatusOK, api.Execution{ExecutionId: uuid.MustParse(execution_id), PlaybookId: playbook_id}) + return } diff --git a/pkg/api/manual/manual_endpoints.go b/pkg/api/manual/manual_endpoints.go deleted file mode 100644 index 16d44e96..00000000 --- a/pkg/api/manual/manual_endpoints.go +++ /dev/null @@ -1,49 +0,0 @@ -package manual - -import ( - "reflect" - "soarca/internal/logger" - - "github.com/gin-gonic/gin" -) - -var log *logger.Log - -type Empty struct{} - -func init() { - log = logger.Logger(reflect.TypeOf(Empty{}).PkgPath(), logger.Info, "", logger.Json) -} - -// TODO: -// The manual API expose general executions-wide information -// Thus, we need a ManualController that implements an IManualController interface -// The API routes will invoke the IManualController interface instance (the ManualController) -// In turn, the manual capability itself will implement, besides the execution capability, -// also some IManualInteraction interface, which will expose and consume information specific -// to a manual interaction (the function that the ManualController will invoke on the manual capability, -// to GET manual/execution-id/step-id, and POST manual/continue). - -// A manual command in CACAO is simply the operation: -// { post_message; wait_for_response (returning a result) } -// -// Agent and target for the command itself make little sense. -// Unless an agent is the intended system that does post_message, and wait_for_response. -// But the targets? For the automated execution, there is no need to specify any. -// -// 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. - -// The controller manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) - -func Routes(route *gin.Engine, manualHandler *ManualHandler) { - group := route.Group("/manual") - { - group.GET("/", manualHandler.GetPendingCommands) - group.GET("/:executionId/:stepId", manualHandler.GetPendingCommand) - group.POST("/manual/continue", manualHandler.PostContinue) - } -} diff --git a/pkg/core/capability/manual/interaction/api/api.go b/pkg/core/capability/manual/interaction/api/api.go deleted file mode 100644 index 778f64ec..00000000 --- a/pkg/core/capability/manual/interaction/api/api.go +++ /dev/null @@ -1 +0,0 @@ -package api diff --git a/pkg/core/capability/manual/interaction/api/api_test.go b/pkg/core/capability/manual/interaction/api/api_test.go deleted file mode 100644 index 778f64ec..00000000 --- a/pkg/core/capability/manual/interaction/api/api_test.go +++ /dev/null @@ -1 +0,0 @@ -package api diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index c694b397..e42fa36b 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -3,7 +3,6 @@ package interaction import ( "reflect" "soarca/internal/logger" - "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "soarca/pkg/models/manual" ) @@ -28,22 +27,23 @@ func init() { // Upon a Continue and relative updates, the IsCompleted will return status == completed, and the related info // The manual capability continues. -type IInteraction interface { - PostCommand(inquiry manual.ManualCommandData) error - Continue(outArgsResult manual.ManualOutArgUpdatePayload) error +type IInteractionIntegrationNotifier interface { + Notify(command manual.ManualCommandData, channel chan manual.ManualOutArgUpdatePayload) +} + +type ICapabilityInteraction interface { + Queue(command manual.ManualCommandData) error } -type IInteractionController interface { - PostCommand(inquiry manual.ManualCommandData) error +type IInteractionStorage interface { GetPendingCommands() ([]manual.ManualCommandData, error) GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) Continue(outArgsResult manual.ManualOutArgUpdatePayload) error - IsCompleted(metadata execution.Metadata) (cacao.Variables, string, error) } type InteractionController struct { PendingCommands map[string]manual.ManualCommandData // Keyed on execution ID - Interactions []IInteraction + notifiers []IInteractionIntegrationNotifier } func (manualController *InteractionController) GetPendingCommands() ([]manual.ManualCommandData, error) { diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 90688a05..c4fd7dee 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -1,7 +1,9 @@ package manual import ( + "soarca/pkg/core/capability" "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" ) type ManualCommandData struct { @@ -25,11 +27,23 @@ type ManualOutArg struct { External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external } +type ManualOutArgs map[string]ManualOutArg + type ManualOutArgUpdatePayload 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 bool `bson:"response_status" json:"response_status" validate:"required"` // Can be either success or failure - ResponseOutArgs map[string]ManualOutArg `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions + 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 bool `bson:"response_status" json:"response_status" validate:"required"` // Can be either success or failure + ResponseOutArgs ManualOutArgs `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions +} + +type InteractionCommand struct { + Metadata execution.Metadata + Context capability.Context +} + +type InteractionResponse struct { + ResponseError error + Variables ManualOutArgs } From 89443e6c4531a4558652c8dd27675ab34afc7f1a Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:43:47 +0100 Subject: [PATCH 13/63] more setup progress and changes to types --- pkg/api/manual/manual_api.go | 3 --- .../manual/interaction/interaction.go | 15 ++++++++++++++- pkg/models/manual/manual.go | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 31a76bec..f427051a 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -70,7 +70,6 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { } g.JSON(http.StatusOK, commands) - return } // manual @@ -109,7 +108,6 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { } g.JSON(http.StatusOK, commandData) - return } // manual @@ -149,5 +147,4 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } g.JSON(http.StatusOK, api.Execution{ExecutionId: uuid.MustParse(execution_id), PlaybookId: playbook_id}) - return } diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index e42fa36b..f668e4bf 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -28,7 +28,7 @@ func init() { // The manual capability continues. type IInteractionIntegrationNotifier interface { - Notify(command manual.ManualCommandData, channel chan manual.ManualOutArgUpdatePayload) + Notify(command manual.InteractionIntegrationCommandData, channel chan manual.InteractionIntegrationResponse) } type ICapabilityInteraction interface { @@ -60,3 +60,16 @@ func (manualController *InteractionController) PostContinue(outArgsResult manual log.Trace("completing manual command") return nil } + +func (manualController *InteractionController) Queue(command manual.InteractionIntegrationCommandData) error { + channel := make(chan manual.InteractionIntegrationResponse) + for _, notifier := range manualController.notifiers { + go notifier.Notify(command, channel) + } + for { + // Skeleton. Implementation todo. Also study what happens if timeout at higher level + result := <-channel + log.Debug(result) + return nil + } +} diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index c4fd7dee..c467fbfa 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -18,6 +18,18 @@ type ManualCommandData struct { OutArgs 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 } +type InteractionIntegrationCommandData 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:"command_is_base64" json:"command_is_base64" validate:"required"` // Indicate the command is in base 64 + Targets map[string]cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command + OutArgs 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 +} + type ManualOutArg struct { Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ Value string `bson:"value" json:"value" validate:"required" example:"this is a value"` // The value of the that the variable will evaluate to @@ -45,5 +57,10 @@ type InteractionCommand struct { type InteractionResponse struct { ResponseError error - Variables ManualOutArgs + OutArgs ManualOutArgUpdatePayload +} + +type InteractionIntegrationResponse struct { + ResponseError error + OutArgs ManualOutArgUpdatePayload } From cc2c809cfb4f0c79b5920d81e12beaa0f65bcd43 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:46:29 +0100 Subject: [PATCH 14/63] add idle wait on chan for manual api flow in docs --- docs/content/en/docs/core-components/modules.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 2d268cea..682cb281 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -413,6 +413,7 @@ interaction -> interaction : idle wait on chan 3ptool --> interaction deactivate 3ptool else Native ManualAPI flow +interaction -> interaction : idle wait on chan api -> interaction : GetPendingCommands() api -> interaction : GetPendingCommand(executionId, stepId) api -> interaction : Continue(InteractionResponse) From aca560711a99695a0739ae097c9378808439229f Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:05:13 +0100 Subject: [PATCH 15/63] clean types in iface signatures, add considerations on async channels --- .../en/docs/core-components/modules.md | 6 +- pkg/api/manual/manual_api.go | 11 ++- .../manual/interaction/interaction.go | 91 ++++++++++++++----- pkg/models/manual/manual.go | 34 +++---- .../api/routes/manual_api/manual_api_test.go | 1 + 5 files changed, 99 insertions(+), 44 deletions(-) create mode 100644 test/integration/api/routes/manual_api/manual_api_test.go diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 682cb281..564fada0 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -364,7 +364,7 @@ interface IInteracionStorage{ } interface IInteractionIntegrationNotifier { - Notify(command InteractionIntegrationCommand, channel chan IntegrationInteractionResponse) + Notify(command InteractionIntegrationCommand, channel chan InteractionIntegrationResponse) } class Interaction { @@ -388,7 +388,7 @@ 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. -Integration's code should implement the *IInteractionIntegrationNotifier* interface, returning the result of the manual command execution in form of an `IntegrationInteractionResponse` object, into the respective channel. +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 the way the manual interactions components work. @@ -409,7 +409,7 @@ activate 3ptool interaction -> interaction : idle wait on chan 3ptool <--> Integration : command posting and handling -3ptool -> 3ptool : post IntegrationInteractionResponse on channel +3ptool -> 3ptool : post InteractionIntegrationResponse on channel 3ptool --> interaction deactivate 3ptool else Native ManualAPI flow diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index f427051a..89d21724 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -57,7 +57,7 @@ type ManualHandler struct { // @Accept json // @Produce json // @Success 200 {object} api.Execution -// @failure 400 {object} []manual.ManualCommandData +// @failure 400 {object} []manual.InteractionCommandData // @Router /manual/ [GET] func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { commands, err := manualHandler.interactionCapability.GetPendingCommands() @@ -82,7 +82,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { // @Produce json // @Param execution_id path string true "execution ID" // @Param step_id path string true "step ID" -// @Success 200 {object} manual.ManualCommandData +// @Success 200 {object} manual.InteractionCommandData // @failure 400 {object} api.Error // @Router /manual/{execution_id}/{step_id} [GET] func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { @@ -146,5 +146,10 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { "POST /manual/continue", err.Error()) return } - g.JSON(http.StatusOK, api.Execution{ExecutionId: uuid.MustParse(execution_id), PlaybookId: playbook_id}) + g.JSON( + http.StatusOK, + api.Execution{ + ExecutionId: uuid.MustParse(execution_id), + PlaybookId: playbook_id, + }) } diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index f668e4bf..aa07c4c9 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -24,36 +24,85 @@ func init() { // The manual capability waits on interactioncontroller.WasCompleted() status != pending (to implement) // Meanwhile, external systems use the InteractionController to do GetPending. GetPending just uses the memory registry of InteractionController // Also meanwhile, external systems can use InteractionController to do Continue() -// Upon a Continue and relative updates, the IsCompleted will return status == completed, and the related info // The manual capability continues. type IInteractionIntegrationNotifier interface { - Notify(command manual.InteractionIntegrationCommandData, channel chan manual.InteractionIntegrationResponse) + Notify(command manual.InteractionIntegrationCommand, channel chan manual.InteractionIntegrationResponse) } type ICapabilityInteraction interface { - Queue(command manual.ManualCommandData) error + Queue(command manual.InteractionCommand, channel chan manual.InteractionResponse) error } type IInteractionStorage interface { - GetPendingCommands() ([]manual.ManualCommandData, error) - GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) + GetPendingCommands() ([]manual.InteractionCommandData, 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.InteractionCommandData, error) Continue(outArgsResult manual.ManualOutArgUpdatePayload) error } type InteractionController struct { - PendingCommands map[string]manual.ManualCommandData // Keyed on execution ID - notifiers []IInteractionIntegrationNotifier + InteractionStorage map[string]manual.InteractionCommandData // Keyed on execution ID + Notifiers []IInteractionIntegrationNotifier } -func (manualController *InteractionController) GetPendingCommands() ([]manual.ManualCommandData, error) { +func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionController { + storage := map[string]manual.InteractionCommandData{} + return &InteractionController{ + InteractionStorage: storage, + Notifiers: manualIntegrations, + } +} + +// ############################################################################ +// ICapabilityInteraction implementation +// ############################################################################ +func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse) error { + + // Note: there is one manual capability per whole execution, which means there is one manualCapabilityChannel per execution + // + + // TODO regsiter pending command in storage + + integrationCommand := manual.InteractionIntegrationCommand{ + Metadata: command.Metadata, + Context: command.Context, + } + + // One response channel for all integrations. First reply resolves the manual command + interactionChannel := make(chan manual.InteractionIntegrationResponse) + + for _, notifier := range manualController.Notifiers { + go notifier.Notify(integrationCommand, interactionChannel) + } + + // Purposedly blocking in idle-wait. We want to receive data back before continuiing the playbook + for { + // Skeleton. Implementation todo. Also study what happens if timeout at higher level + // Also study what happens with concurrent manual commands e.g. from parallel steps, + // with respect to using one class channel or different channels per call + result := <-interactionChannel + + // TODO: check register for pending manual command + // If was already resolved, safely discard + // Otherwise, resolve command, post back to manual capability, de-register command form pending + + log.Debug(result) + return nil + } +} + +// ############################################################################ +// IInteractionStorage implementation +// ############################################################################ +func (manualController *InteractionController) GetPendingCommands() ([]manual.InteractionCommandData, error) { log.Trace("getting pending manual commands") - return []manual.ManualCommandData{}, nil + return []manual.InteractionCommandData{}, nil } -func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.ManualCommandData, error) { +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, error) { log.Trace("getting pending manual command") - return manual.ManualCommandData{}, nil + return manual.InteractionCommandData{}, nil } func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) error { @@ -61,15 +110,13 @@ func (manualController *InteractionController) PostContinue(outArgsResult manual return nil } -func (manualController *InteractionController) Queue(command manual.InteractionIntegrationCommandData) error { - channel := make(chan manual.InteractionIntegrationResponse) - for _, notifier := range manualController.notifiers { - go notifier.Notify(command, channel) - } - for { - // Skeleton. Implementation todo. Also study what happens if timeout at higher level - result := <-channel - log.Debug(result) - return nil - } +// ############################################################################ +// Utilities and functionalities +// ############################################################################ +func (manualController *InteractionController) registerPendingInteraction() { + // TODO +} + +func (manualController *InteractionController) continueInteraction() { + // TODO } diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index c467fbfa..2a0567fc 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -6,7 +6,11 @@ import ( "soarca/pkg/models/execution" ) -type ManualCommandData struct { +// ################################################################################ +// Data structures for native SOARCA manual command handling +// ################################################################################ + +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 @@ -18,18 +22,12 @@ type ManualCommandData struct { OutArgs 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 } -type InteractionIntegrationCommandData 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:"command_is_base64" json:"command_is_base64" validate:"required"` // Indicate the command is in base 64 - Targets map[string]cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command - OutArgs 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 +type InteractionCommand struct { + Metadata execution.Metadata + Context capability.Context } +// Alike to the cacao.Variable, but with different required fields type ManualOutArg struct { Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ Value string `bson:"value" json:"value" validate:"required" example:"this is a value"` // The value of the that the variable will evaluate to @@ -50,16 +48,20 @@ type ManualOutArgUpdatePayload struct { ResponseOutArgs ManualOutArgs `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions } -type InteractionCommand struct { - Metadata execution.Metadata - Context capability.Context -} - type InteractionResponse struct { ResponseError error OutArgs ManualOutArgUpdatePayload } +// ################################################################################ +// Data structures for integrations manual command handling +// ################################################################################ + +type InteractionIntegrationCommand struct { + Metadata execution.Metadata + Context capability.Context +} + type InteractionIntegrationResponse struct { ResponseError error OutArgs ManualOutArgUpdatePayload 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..edc98b80 --- /dev/null +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -0,0 +1 @@ +package manual_api_test From 4aca409998f697db69782efe70eeb9fa02db240c Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:00:36 +0100 Subject: [PATCH 16/63] add go routine in interaction object to prevent deadlocks --- .../manual/interaction/interaction.go | 95 ++++++++++++++----- pkg/models/manual/manual.go | 18 ++-- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index aa07c4c9..046e33a8 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -1,6 +1,7 @@ package interaction import ( + "fmt" "reflect" "soarca/internal/logger" "soarca/pkg/models/execution" @@ -42,12 +43,12 @@ type IInteractionStorage interface { } type InteractionController struct { - InteractionStorage map[string]manual.InteractionCommandData // Keyed on execution ID + InteractionStorage map[string]map[string]manual.InteractionCommandData // Keyed on [executionID][stepID] Notifiers []IInteractionIntegrationNotifier } func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionController { - storage := map[string]manual.InteractionCommandData{} + storage := map[string]map[string]manual.InteractionCommandData{} return &InteractionController{ InteractionStorage: storage, Notifiers: manualIntegrations, @@ -59,37 +60,38 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // ############################################################################ func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse) error { - // Note: there is one manual capability per whole execution, which means there is one manualCapabilityChannel per execution - // - - // TODO regsiter pending command in storage - - integrationCommand := manual.InteractionIntegrationCommand{ - Metadata: command.Metadata, - Context: command.Context, + err := manualController.registerPendingInteraction(command) + if err != nil { + return err } + // Copy and type conversion + integrationCommand := manual.InteractionIntegrationCommand(command) + // One response channel for all integrations. First reply resolves the manual command interactionChannel := make(chan manual.InteractionIntegrationResponse) + defer close(interactionChannel) for _, notifier := range manualController.Notifiers { go notifier.Notify(integrationCommand, interactionChannel) } // Purposedly blocking in idle-wait. We want to receive data back before continuiing the playbook - for { - // Skeleton. Implementation todo. Also study what happens if timeout at higher level - // Also study what happens with concurrent manual commands e.g. from parallel steps, - // with respect to using one class channel or different channels per call - result := <-interactionChannel - - // TODO: check register for pending manual command - // If was already resolved, safely discard - // Otherwise, resolve command, post back to manual capability, de-register command form pending - - log.Debug(result) - return nil - } + go func() { + for { + // Skeleton. Implementation todo. Also study what happens if timeout at higher level + // Also study what happens with concurrent manual commands e.g. from parallel steps, + // with respect to using one class channel or different channels per call + result := <-interactionChannel + + // TODO: check register for pending manual command + // If was already resolved, safely discard + // Otherwise, resolve command, post back to manual capability, de-register command form pending + + log.Debug(result) + } + }() + return nil } // ############################################################################ @@ -113,10 +115,51 @@ func (manualController *InteractionController) PostContinue(outArgsResult manual // ############################################################################ // Utilities and functionalities // ############################################################################ -func (manualController *InteractionController) registerPendingInteraction() { - // TODO +func (manualController *InteractionController) registerPendingInteraction(command manual.InteractionCommand) error { + + interaction := manual.InteractionCommandData{ + Type: command.Context.Command.Type, + ExecutionId: command.Metadata.ExecutionId.String(), + PlaybookId: command.Metadata.PlaybookId, + StepId: command.Metadata.StepId, + Description: command.Context.Command.Description, + Command: command.Context.Command.Command, + CommandBase64: command.Context.Command.CommandB64, + Target: command.Context.Target, + OutArgs: command.Context.Variables, + } + + execution, ok := manualController.InteractionStorage[interaction.ExecutionId] + + if !ok { + // It's fine, no entry for execution registered. Register execution and step entry + manualController.InteractionStorage[interaction.ExecutionId] = map[string]manual.InteractionCommandData{ + interaction.StepId: interaction, + } + return nil + } + + // There is an execution entry + if _, ok := execution[interaction.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.", + interaction.ExecutionId, interaction.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[interaction.StepId] = interaction + + return nil } -func (manualController *InteractionController) continueInteraction() { +func (manualController *InteractionController) continueInteraction(interactionResponse manual.InteractionResponse) error { // TODO + if interactionResponse.ResponseError != nil { + return interactionResponse.ResponseError + } + return nil } diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 2a0567fc..b8f10203 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -11,15 +11,15 @@ import ( // ################################################################################ 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:"command_is_base64" json:"command_is_base64" validate:"required"` // Indicate the command is in base 64 - Targets map[string]cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command - OutArgs 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 + 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 + CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present + Target cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command + OutArgs 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 } type InteractionCommand struct { From ee49870093efcd844621e55474c4c75be7d1f27b Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:02:50 +0100 Subject: [PATCH 17/63] comment unused func --- .../capability/manual/interaction/interaction.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 046e33a8..0d9a7875 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -156,10 +156,10 @@ func (manualController *InteractionController) registerPendingInteraction(comman return nil } -func (manualController *InteractionController) continueInteraction(interactionResponse manual.InteractionResponse) error { - // TODO - if interactionResponse.ResponseError != nil { - return interactionResponse.ResponseError - } - return nil -} +// func (manualController *InteractionController) continueInteraction(interactionResponse manual.InteractionResponse) error { +// // TODO +// if interactionResponse.ResponseError != nil { +// return interactionResponse.ResponseError +// } +// return nil +// } From a173cde2476f97d25eb0ae106d63ccf384e7fa43 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 13:54:43 +0100 Subject: [PATCH 18/63] normalize capability and interaction --- pkg/core/capability/manual/manual.go | 25 +++++++++++++++---- pkg/core/capability/manual/manual_test.go | 14 +++++------ pkg/interaction/interaction.go | 21 ---------------- pkg/models/manual/manual.go | 17 +++++++++++-- .../mock_interaction/mock_interaction.go | 6 ++--- 5 files changed, 45 insertions(+), 38 deletions(-) delete mode 100644 pkg/interaction/interaction.go diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index 5e697146..415744c7 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -5,9 +5,10 @@ import ( "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" ) @@ -23,7 +24,7 @@ const ( ) func New(controller interaction.ICapabilityInteraction, - channel chan interaction.InteractionResponse) ManualCapability { + channel chan manualModel.InteractionResponse) ManualCapability { // channel := make(chan interaction.InteractionResponse) return ManualCapability{interaction: controller, channel: channel} } @@ -34,7 +35,7 @@ func init() { type ManualCapability struct { interaction interaction.ICapabilityInteraction - channel chan interaction.InteractionResponse + channel chan manualModel.InteractionResponse } func (manual *ManualCapability) GetType() string { @@ -45,7 +46,7 @@ func (manual *ManualCapability) Execute( metadata execution.Metadata, commandContext capability.Context) (cacao.Variables, error) { - command := interaction.InteractionCommand{Metadata: metadata, Context: commandContext} + command := manualModel.InteractionCommand{Metadata: metadata, Context: commandContext} err := manual.interaction.Queue(command, manual.channel) if err != nil { @@ -70,12 +71,26 @@ func (manual *ManualCapability) awaitUserInput(timeout time.Duration) (cacao.Var return cacao.NewVariables(), err case response := <-manual.channel: log.Trace("received response from api") - return response.Variables, response.ResponseError + cacaoVars := manual.copyOutArgsToVars(response.OutArgs.ResponseOutArgs) + return cacaoVars, response.ResponseError } } } +func (manual *ManualCapability) copyOutArgsToVars(outArgs manualModel.ManualOutArgs) cacao.Variables { + vars := cacao.NewVariables() + for name, outVar := range outArgs { + + vars[name] = cacao.Variable{ + Type: outVar.Type, + Name: outVar.Name, + Value: outVar.Value, + } + } + return vars +} + func (manual *ManualCapability) getTimeoutValue(userTimeout int) time.Duration { if userTimeout == 0 { log.Warning("timeout is not set or set to 0 fallback timeout of 1 minute is used to complete step") diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index 72608ffd..74bf8fe9 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -2,8 +2,8 @@ package manual import ( "soarca/pkg/core/capability" - "soarca/pkg/interaction" "soarca/pkg/models/execution" + manualModel "soarca/pkg/models/manual" "soarca/test/unittest/mocks/mock_interaction" "testing" "time" @@ -11,22 +11,22 @@ import ( "github.com/go-playground/assert/v2" ) -func returnQueueCall(channel chan interaction.InteractionResponse) { +func returnQueueCall(channel chan manualModel.InteractionResponse) { time.Sleep(time.Millisecond * 10) - response := interaction.InteractionResponse{} + response := manualModel.InteractionResponse{} channel <- response } func TestManualExecution(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) + channel := make(chan manualModel.InteractionResponse) manual := New(&interactionMock, channel) meta := execution.Metadata{} context := capability.Context{} - command := interaction.InteractionCommand{} + command := manualModel.InteractionCommand{} go returnQueueCall(channel) interactionMock.On("Queue", command, channel).Return(nil) vars, err := manual.Execute(meta, context) @@ -37,7 +37,7 @@ func TestManualExecution(t *testing.T) { func TestTimetoutCalculationNotSet(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) + channel := make(chan manualModel.InteractionResponse) manual := New(&interactionMock, channel) timeout := manual.getTimeoutValue(0) assert.Equal(t, timeout, time.Minute) @@ -45,7 +45,7 @@ func TestTimetoutCalculationNotSet(t *testing.T) { func TestTimetoutCalculation(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan interaction.InteractionResponse) + channel := make(chan manualModel.InteractionResponse) manual := New(&interactionMock, channel) 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/manual/manual.go b/pkg/models/manual/manual.go index b8f10203..1f95283b 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -10,6 +10,7 @@ import ( // Data structures for native SOARCA manual command handling // ################################################################################ +// Object stored in interaction storage and provided back from the API 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 @@ -22,23 +23,27 @@ type InteractionCommandData struct { OutArgs 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 } +// Object passed by the manual capability to the Interaction module type InteractionCommand struct { Metadata execution.Metadata Context capability.Context } -// Alike to the cacao.Variable, but with different required fields +// The variables returned to SOARCA from a manual interaction +// Alike to the cacao.Variable, but with only type name and value required type ManualOutArg struct { + Type string `bson:"type,omitempty" json:"type,omitempty" example:"string"` // Type of the variable should be OASIS variable-type-ov Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ Value string `bson:"value" json:"value" validate:"required" example:"this is a value"` // The value of the that the variable will evaluate to - Type string `bson:"type,omitempty" json:"type,omitempty" example:"string"` // Type of the variable should be OASIS variable-type-ov Description string `bson:"description,omitempty" json:"description,omitempty" example:"some string"` // A description of the variable Constant bool `bson:"constant,omitempty" json:"constant,omitempty" example:"false"` // Indicate if it's a constant External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external } +// The collection of out args mapped per variable name type ManualOutArgs map[string]ManualOutArg +// The object posted on the manual API Continue() payload type ManualOutArgUpdatePayload 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 @@ -48,6 +53,7 @@ type ManualOutArgUpdatePayload struct { ResponseOutArgs ManualOutArgs `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions } +// The object that the Interaction module presents back to the manual capability type InteractionResponse struct { ResponseError error OutArgs ManualOutArgUpdatePayload @@ -57,11 +63,18 @@ type InteractionResponse struct { // Data structures for integrations manual command handling // ################################################################################ +// As manual interaction integrations are called on go routines, this +// duplications prevents inconsistencies on the objects by forcing +// full deep copies of the objects. + +// The command that the Interactin module notifies to the integrations type InteractionIntegrationCommand struct { Metadata execution.Metadata Context capability.Context } +// The payload that an integration puts back on a channel for the Interaction module +// to receive type InteractionIntegrationResponse struct { ResponseError error OutArgs ManualOutArgUpdatePayload diff --git a/test/unittest/mocks/mock_interaction/mock_interaction.go b/test/unittest/mocks/mock_interaction/mock_interaction.go index 3a518029..db429e06 100644 --- a/test/unittest/mocks/mock_interaction/mock_interaction.go +++ b/test/unittest/mocks/mock_interaction/mock_interaction.go @@ -1,7 +1,7 @@ package mock_interaction import ( - "soarca/pkg/interaction" + "soarca/pkg/models/manual" "github.com/stretchr/testify/mock" ) @@ -10,8 +10,8 @@ type MockInteraction struct { mock.Mock } -func (mock *MockInteraction) Queue(command interaction.InteractionCommand, - channel chan interaction.InteractionResponse) error { +func (mock *MockInteraction) Queue(command manual.InteractionCommand, + channel chan manual.InteractionResponse) error { args := mock.Called(command, channel) return args.Error(0) } From 724f562f600df03927984410c758853ab77deff2 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:44:20 +0100 Subject: [PATCH 19/63] move creation of manual capability channel to Execute fcn --- .../manual/interaction/interaction.go | 31 +++++++----- pkg/core/capability/manual/manual.go | 31 +++++++----- pkg/core/capability/manual/manual_test.go | 49 ++++++++++++++----- .../mock_interaction/mock_interaction.go | 20 +++++++- 4 files changed, 94 insertions(+), 37 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 0d9a7875..e6a756f8 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -1,6 +1,7 @@ package interaction import ( + "context" "fmt" "reflect" "soarca/internal/logger" @@ -32,7 +33,7 @@ type IInteractionIntegrationNotifier interface { } type ICapabilityInteraction interface { - Queue(command manual.InteractionCommand, channel chan manual.InteractionResponse) error + Queue(command manual.InteractionCommand, channel chan manual.InteractionResponse, ctx context.Context) error } type IInteractionStorage interface { @@ -58,7 +59,7 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // ############################################################################ // ICapabilityInteraction implementation // ############################################################################ -func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse) error { +func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse, ctx context.Context) error { err := manualController.registerPendingInteraction(command) if err != nil { @@ -68,7 +69,7 @@ func (manualController *InteractionController) Queue(command manual.InteractionC // Copy and type conversion integrationCommand := manual.InteractionIntegrationCommand(command) - // One response channel for all integrations. First reply resolves the manual command + // One response channel for all integrations interactionChannel := make(chan manual.InteractionIntegrationResponse) defer close(interactionChannel) @@ -77,21 +78,29 @@ func (manualController *InteractionController) Queue(command manual.InteractionC } // Purposedly blocking in idle-wait. We want to receive data back before continuiing the playbook - go func() { - for { - // Skeleton. Implementation todo. Also study what happens if timeout at higher level - // Also study what happens with concurrent manual commands e.g. from parallel steps, - // with respect to using one class channel or different channels per call - result := <-interactionChannel + go manualController.awaitIntegrationsResponse(interactionChannel, ctx) + return nil +} + +func (manualController *InteractionController) awaitIntegrationsResponse(interactionChannel chan manual.InteractionIntegrationResponse, ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug("context canceled due to timeout. exiting goroutine") + return + // Skeleton. Implementation todo. Also study what happens if timeout at higher level + // Also study what happens with concurrent manual commands e.g. from parallel steps, + // with respect to using one class channel or different channels per call + case result := <-interactionChannel: + // First reply resolves the manual command // TODO: check register for pending manual command // If was already resolved, safely discard // Otherwise, resolve command, post back to manual capability, de-register command form pending log.Debug(result) } - }() - return nil + } } // ############################################################################ diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index 415744c7..ff33b6fb 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -1,6 +1,7 @@ package manual import ( + "context" "errors" "reflect" "soarca/internal/logger" @@ -23,10 +24,8 @@ const ( fallbackTimeout = time.Minute * 1 ) -func New(controller interaction.ICapabilityInteraction, - channel chan manualModel.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() { @@ -35,7 +34,6 @@ func init() { type ManualCapability struct { interaction interaction.ICapabilityInteraction - channel chan manualModel.InteractionResponse } func (manual *ManualCapability) GetType() string { @@ -48,12 +46,21 @@ func (manual *ManualCapability) Execute( command := manualModel.InteractionCommand{Metadata: metadata, Context: commandContext} - err := manual.interaction.Queue(command, manual.channel) + 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, channel, ctx) 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 } @@ -61,15 +68,15 @@ 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") cacaoVars := manual.copyOutArgsToVars(response.OutArgs.ResponseOutArgs) return cacaoVars, response.ResponseError diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index 74bf8fe9..bfa9e0dc 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -5,10 +5,12 @@ import ( "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 manualModel.InteractionResponse) { @@ -20,33 +22,56 @@ func returnQueueCall(channel chan manualModel.InteractionResponse) { func TestManualExecution(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan manualModel.InteractionResponse) - manual := New(&interactionMock, channel) + var capturedChannel chan manualModel.InteractionResponse + + manual := New(&interactionMock) meta := execution.Metadata{} - context := capability.Context{} + commandContext := capability.Context{} command := manualModel.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) + + // Capture the channel passed to Queue + interactionMock.On("Queue", command, mock_interaction.AnyChannel(), mock_interaction.AnyContext()).Return(nil).Run(func(args mock.Arguments) { + capturedChannel = args.Get(1).(chan manualModel.InteractionResponse) + }) + + // 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) + capturedChannel <- manualModel.InteractionResponse{ + OutArgs: manualModel.ManualOutArgUpdatePayload{ + ResponseOutArgs: manualModel.ManualOutArgs{ + "example": {Value: "example_value"}, + }, + }, + } + + // Wait for the Execute method to complete + wg.Wait() } func TestTimetoutCalculationNotSet(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - channel := make(chan manualModel.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 manualModel.InteractionResponse) - manual := New(&interactionMock, channel) + manual := New(&interactionMock) timeout := manual.getTimeoutValue(1) assert.Equal(t, timeout, time.Millisecond*1) } diff --git a/test/unittest/mocks/mock_interaction/mock_interaction.go b/test/unittest/mocks/mock_interaction/mock_interaction.go index db429e06..c5f0096c 100644 --- a/test/unittest/mocks/mock_interaction/mock_interaction.go +++ b/test/unittest/mocks/mock_interaction/mock_interaction.go @@ -1,6 +1,7 @@ package mock_interaction import ( + "context" "soarca/pkg/models/manual" "github.com/stretchr/testify/mock" @@ -11,7 +12,22 @@ type MockInteraction struct { } func (mock *MockInteraction) Queue(command manual.InteractionCommand, - channel chan manual.InteractionResponse) error { - args := mock.Called(command, channel) + channel chan manual.InteractionResponse, + ctx context.Context) error { + args := mock.Called(command, channel, ctx) 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 + }) +} From 8491a067950cbb3c972f0e29314d99ba60c796b2 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:14:06 +0100 Subject: [PATCH 20/63] implement interaction integration responses but must test lol --- .../manual/interaction/interaction.go | 107 ++++++++++++------ .../manual/interaction/interaction_test.go | 2 +- pkg/core/capability/manual/manual.go | 2 +- pkg/core/capability/manual/manual_test.go | 2 +- pkg/models/manual/manual.go | 4 +- 5 files changed, 80 insertions(+), 37 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index e6a756f8..fa8e191c 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -7,6 +7,8 @@ import ( "soarca/internal/logger" "soarca/pkg/models/execution" "soarca/pkg/models/manual" + + "github.com/google/uuid" ) type Empty struct{} @@ -18,16 +20,6 @@ func init() { log = logger.Logger(component, logger.Info, "", logger.Json) } -// NOTE: -// The InteractionController is injected with all configured Interactions (SOARCA API always, plus AT MOST ONE integration) -// The manual capability is injected with the InteractionController -// The manual capability triggers interactioncontroller.PostCommand -// The InteractionController register a manual command pending in its memory registry -// The manual capability waits on interactioncontroller.WasCompleted() status != pending (to implement) -// Meanwhile, external systems use the InteractionController to do GetPending. GetPending just uses the memory registry of InteractionController -// Also meanwhile, external systems can use InteractionController to do Continue() -// The manual capability continues. - type IInteractionIntegrationNotifier interface { Notify(command manual.InteractionIntegrationCommand, channel chan manual.InteractionIntegrationResponse) } @@ -78,31 +70,47 @@ func (manualController *InteractionController) Queue(command manual.InteractionC } // Purposedly blocking in idle-wait. We want to receive data back before continuiing the playbook - go manualController.awaitIntegrationsResponse(interactionChannel, ctx) + go func() { + defer close(interactionChannel) + for { + select { + case <-ctx.Done(): + log.Debug("context canceled due to timeout. exiting goroutine") + return + + case result := <-interactionChannel: + // Check register for pending manual command + metadata := execution.Metadata{ + ExecutionId: uuid.MustParse(result.Payload.ExecutionId), + PlaybookId: result.Payload.PlaybookId, + StepId: result.Payload.StepId, + } + + _, err := manualController.getPendingInteraction(metadata) + if err != nil { + // If not in there, was already resolved + log.Warning(err) + log.Warning("manual command not found among pending ones. should be already resolved") + return + } + + // Was there. It's resolved, so it's removed from the pendings register + manualController.removeInteractionFromPending(metadata) + + responseToCapanility := manual.InteractionResponse{ + ResponseError: result.ResponseError, + Payload: result.Payload, + } + + manualCapabilityChannel <- responseToCapanility + return + } + } + }() return nil } -func (manualController *InteractionController) awaitIntegrationsResponse(interactionChannel chan manual.InteractionIntegrationResponse, ctx context.Context) { - for { - select { - case <-ctx.Done(): - log.Debug("context canceled due to timeout. exiting goroutine") - return - // Skeleton. Implementation todo. Also study what happens if timeout at higher level - // Also study what happens with concurrent manual commands e.g. from parallel steps, - // with respect to using one class channel or different channels per call - case result := <-interactionChannel: - // First reply resolves the manual command - // TODO: check register for pending manual command - // If was already resolved, safely discard - // Otherwise, resolve command, post back to manual capability, de-register command form pending - - log.Debug(result) - } - } -} - // ############################################################################ // IInteractionStorage implementation // ############################################################################ @@ -113,7 +121,7 @@ func (manualController *InteractionController) GetPendingCommands() ([]manual.In func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, error) { log.Trace("getting pending manual command") - return manual.InteractionCommandData{}, nil + return manualController.getPendingInteraction(metadata) } func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) error { @@ -165,6 +173,41 @@ func (manualController *InteractionController) registerPendingInteraction(comman return nil } +func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionCommandData, error) { + executionCommands, ok := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] + if !ok { + err := fmt.Errorf("no pending commands found for execution %s", commandMetadata.ExecutionId.String()) + return manual.InteractionCommandData{}, err + } + commandData, ok := executionCommands[commandMetadata.StepId] + if !ok { + err := fmt.Errorf("no pending commands found for execution %s -> step %s", + commandMetadata.ExecutionId.String(), + commandMetadata.StepId, + ) + return manual.InteractionCommandData{}, err + } + return commandData, 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) continueInteraction(interactionResponse manual.InteractionResponse) error { // // TODO // if interactionResponse.ResponseError != nil { diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 557ceaed..2456d153 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -4,6 +4,6 @@ import ( "testing" ) -func TestHello(t *testing.T) { +func TestSAreHardToImplement(t *testing.T) { } diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index ff33b6fb..731c912f 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -78,7 +78,7 @@ func (manual *ManualCapability) awaitUserInput(channel chan manualModel.Interact return cacao.NewVariables(), err case response := <-channel: log.Trace("received response from api") - cacaoVars := manual.copyOutArgsToVars(response.OutArgs.ResponseOutArgs) + cacaoVars := manual.copyOutArgsToVars(response.Payload.ResponseOutArgs) return cacaoVars, response.ResponseError } diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index bfa9e0dc..9bbafbbe 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -50,7 +50,7 @@ func TestManualExecution(t *testing.T) { // Simulate the response after ensuring the channel is captured time.Sleep(100 * time.Millisecond) capturedChannel <- manualModel.InteractionResponse{ - OutArgs: manualModel.ManualOutArgUpdatePayload{ + Payload: manualModel.ManualOutArgUpdatePayload{ ResponseOutArgs: manualModel.ManualOutArgs{ "example": {Value: "example_value"}, }, diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 1f95283b..2b7a6b0a 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -56,7 +56,7 @@ type ManualOutArgUpdatePayload struct { // The object that the Interaction module presents back to the manual capability type InteractionResponse struct { ResponseError error - OutArgs ManualOutArgUpdatePayload + Payload ManualOutArgUpdatePayload } // ################################################################################ @@ -77,5 +77,5 @@ type InteractionIntegrationCommand struct { // to receive type InteractionIntegrationResponse struct { ResponseError error - OutArgs ManualOutArgUpdatePayload + Payload ManualOutArgUpdatePayload } From 0d5bfa2e3b23585ba68894fb8e5bb38e68cfaefd Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:17:50 +0100 Subject: [PATCH 21/63] small refactor for fcn complexity --- .../manual/interaction/interaction.go | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index fa8e191c..32c4fbc7 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -69,46 +69,48 @@ func (manualController *InteractionController) Queue(command manual.InteractionC go notifier.Notify(integrationCommand, interactionChannel) } - // Purposedly blocking in idle-wait. We want to receive data back before continuiing the playbook - go func() { - defer close(interactionChannel) - for { - select { - case <-ctx.Done(): - log.Debug("context canceled due to timeout. exiting goroutine") - return + // Async idle wait on interaction integration channel + go manualController.waitInteractionIntegrationResponse(manualCapabilityChannel, ctx, interactionChannel) + + return nil +} + +func (manualController *InteractionController) waitInteractionIntegrationResponse(manualCapabilityChannel chan manual.InteractionResponse, ctx context.Context, interactionChannel chan manual.InteractionIntegrationResponse) { + defer close(interactionChannel) + for { + select { + case <-ctx.Done(): + log.Debug("context canceled due to timeout. exiting goroutine") + return + + case result := <-interactionChannel: + // Check register for pending manual command + metadata := execution.Metadata{ + ExecutionId: uuid.MustParse(result.Payload.ExecutionId), + PlaybookId: result.Payload.PlaybookId, + StepId: result.Payload.StepId, + } - case result := <-interactionChannel: - // Check register for pending manual command - metadata := execution.Metadata{ - ExecutionId: uuid.MustParse(result.Payload.ExecutionId), - PlaybookId: result.Payload.PlaybookId, - StepId: result.Payload.StepId, - } - - _, err := manualController.getPendingInteraction(metadata) - if err != nil { - // If not in there, was already resolved - log.Warning(err) - log.Warning("manual command not found among pending ones. should be already resolved") - return - } - - // Was there. It's resolved, so it's removed from the pendings register - manualController.removeInteractionFromPending(metadata) - - responseToCapanility := manual.InteractionResponse{ - ResponseError: result.ResponseError, - Payload: result.Payload, - } - - manualCapabilityChannel <- responseToCapanility + _, err := manualController.getPendingInteraction(metadata) + if err != nil { + // If not in there, was already resolved + log.Warning(err) + log.Warning("manual command not found among pending ones. should be already resolved") return } - } - }() - return nil + // Was there. It's resolved, so it's removed from the pendings register + manualController.removeInteractionFromPending(metadata) + + interactionResponse := manual.InteractionResponse{ + ResponseError: result.ResponseError, + Payload: result.Payload, + } + + manualCapabilityChannel <- interactionResponse + return + } + } } // ############################################################################ From 7a8368893c615f18c481d27ffbbfc7b3057c7c5f Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:28:40 +0100 Subject: [PATCH 22/63] implement all but PostContinue and still missing all unit tests --- .../manual/interaction/interaction.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 32c4fbc7..d1175b1e 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -118,7 +118,7 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons // ############################################################################ func (manualController *InteractionController) GetPendingCommands() ([]manual.InteractionCommandData, error) { log.Trace("getting pending manual commands") - return []manual.InteractionCommandData{}, nil + return manualController.getAllPendingInteractions(), nil } func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, error) { @@ -128,6 +128,12 @@ func (manualController *InteractionController) GetPendingCommand(metadata execut func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) error { log.Trace("completing manual command") + // TODO + // Get execution metadata from updatepayload + // Check command is indeed pending + // If not, it means it was already solved (right?) + // If it is, put outArgs back into manualCapabilityChannel (must figure out how...) + // de-register the command return nil } @@ -175,6 +181,16 @@ func (manualController *InteractionController) registerPendingInteraction(comman return nil } +func (manualController *InteractionController) getAllPendingInteractions() []manual.InteractionCommandData { + allPendingInteractions := []manual.InteractionCommandData{} + for _, interactions := range manualController.InteractionStorage { + for _, interaction := range interactions { + allPendingInteractions = append(allPendingInteractions, interaction) + } + } + return allPendingInteractions +} + func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionCommandData, error) { executionCommands, ok := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] if !ok { From 2e362c36d6fa4296be8d838f8d222d83f9a0c1c5 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:33:53 +0100 Subject: [PATCH 23/63] fix lint --- .../capability/manual/interaction/interaction.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index d1175b1e..22bc6e95 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -91,21 +91,17 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons StepId: result.Payload.StepId, } - _, err := manualController.getPendingInteraction(metadata) + // Remove interaction from pending ones + err := manualController.removeInteractionFromPending(metadata) if err != nil { - // If not in there, was already resolved + // If it was not there, was already resolved log.Warning(err) log.Warning("manual command not found among pending ones. should be already resolved") return } - // Was there. It's resolved, so it's removed from the pendings register - manualController.removeInteractionFromPending(metadata) - - interactionResponse := manual.InteractionResponse{ - ResponseError: result.ResponseError, - Payload: result.Payload, - } + // Copy result and conversion back to interactionResponse format + interactionResponse := manual.InteractionResponse(result) manualCapabilityChannel <- interactionResponse return From 97ce44ce43f342b49a03391fd3134128bc4b5e6a Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:36:09 +0100 Subject: [PATCH 24/63] fix lint --- pkg/core/capability/manual/manual_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index 9bbafbbe..ff00fc93 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -13,13 +13,6 @@ import ( "github.com/stretchr/testify/mock" ) -func returnQueueCall(channel chan manualModel.InteractionResponse) { - - time.Sleep(time.Millisecond * 10) - response := manualModel.InteractionResponse{} - channel <- response -} - func TestManualExecution(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} var capturedChannel chan manualModel.InteractionResponse From 73ea8b3fb02feb12daebba454957204e849f90a4 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:00:44 +0100 Subject: [PATCH 25/63] add postContinue api call --- pkg/api/manual/manual_api.go | 12 +-- .../manual/interaction/interaction.go | 84 +++++++++++++------ pkg/models/manual/manual.go | 5 ++ 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 89d21724..d8ba1381 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -60,7 +60,7 @@ type ManualHandler struct { // @failure 400 {object} []manual.InteractionCommandData // @Router /manual/ [GET] func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { - commands, err := manualHandler.interactionCapability.GetPendingCommands() + commands, status, err := manualHandler.interactionCapability.GetPendingCommands() if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -68,7 +68,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { "GET /manual/", err.Error()) return } - g.JSON(http.StatusOK, + g.JSON(status, commands) } @@ -98,7 +98,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { } executionMetadata := execution.Metadata{ExecutionId: execId, StepId: step_id} - commandData, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) + commandData, status, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -106,7 +106,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { "GET /manual/"+execution_id+"/"+step_id, err.Error()) return } - g.JSON(http.StatusOK, + g.JSON(status, commandData) } @@ -138,7 +138,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { PlaybookId: playbook_id, StepId: step_id, } - err := manualHandler.interactionCapability.Continue(outArgsUpdate) + status, err := manualHandler.interactionCapability.Continue(outArgsUpdate) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -147,7 +147,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } g.JSON( - http.StatusOK, + status, api.Execution{ ExecutionId: uuid.MustParse(execution_id), PlaybookId: playbook_id, diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 22bc6e95..4db6de20 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -3,6 +3,7 @@ package interaction import ( "context" "fmt" + "net/http" "reflect" "soarca/internal/logger" "soarca/pkg/models/execution" @@ -29,19 +30,19 @@ type ICapabilityInteraction interface { } type IInteractionStorage interface { - GetPendingCommands() ([]manual.InteractionCommandData, error) + GetPendingCommands() ([]manual.InteractionCommandData, int, 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.InteractionCommandData, error) - Continue(outArgsResult manual.ManualOutArgUpdatePayload) error + GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, int, error) + Continue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) } type InteractionController struct { - InteractionStorage map[string]map[string]manual.InteractionCommandData // Keyed on [executionID][stepID] + InteractionStorage map[string]map[string]manual.InteractionStorageEntry // Keyed on [executionID][stepID] Notifiers []IInteractionIntegrationNotifier } func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionController { - storage := map[string]map[string]manual.InteractionCommandData{} + storage := map[string]map[string]manual.InteractionStorageEntry{} return &InteractionController{ InteractionStorage: storage, Notifiers: manualIntegrations, @@ -53,7 +54,7 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // ############################################################################ func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse, ctx context.Context) error { - err := manualController.registerPendingInteraction(command) + err := manualController.registerPendingInteraction(command, manualCapabilityChannel) if err != nil { return err } @@ -112,31 +113,56 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons // ############################################################################ // IInteractionStorage implementation // ############################################################################ -func (manualController *InteractionController) GetPendingCommands() ([]manual.InteractionCommandData, error) { +func (manualController *InteractionController) GetPendingCommands() ([]manual.InteractionCommandData, int, error) { log.Trace("getting pending manual commands") - return manualController.getAllPendingInteractions(), nil + return manualController.getAllPendingInteractions(), http.StatusOK, nil } -func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, error) { +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, int, error) { log.Trace("getting pending manual command") - return manualController.getPendingInteraction(metadata) + interaction, err := manualController.getPendingInteraction(metadata) + // TODO: determine status code + return interaction.CommandData, http.StatusOK, err } -func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) error { +func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) { log.Trace("completing manual command") - // TODO - // Get execution metadata from updatepayload - // Check command is indeed pending + + metadata := execution.Metadata{ + ExecutionId: uuid.MustParse(outArgsResult.ExecutionId), + PlaybookId: outArgsResult.PlaybookId, + StepId: outArgsResult.StepId, + } + + // TODO: determine status code + // If not, it means it was already solved (right?) - // If it is, put outArgs back into manualCapabilityChannel (must figure out how...) + pendingEntry, err := manualController.getPendingInteraction(metadata) + if err != nil { + log.Warning(err) + return http.StatusAlreadyReported, err + } + + // If it is, put outArgs back into manualCapabilityChannel + + pendingEntry.Channel <- manual.InteractionResponse{ + ResponseError: nil, + Payload: outArgsResult, + } // de-register the command - return nil + err = manualController.removeInteractionFromPending(metadata) + if err != nil { + log.Error(err) + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil } // ############################################################################ // Utilities and functionalities // ############################################################################ -func (manualController *InteractionController) registerPendingInteraction(command manual.InteractionCommand) error { +func (manualController *InteractionController) registerPendingInteraction(command manual.InteractionCommand, manualChan chan manual.InteractionResponse) error { interaction := manual.InteractionCommandData{ Type: command.Context.Command.Type, @@ -154,8 +180,11 @@ func (manualController *InteractionController) registerPendingInteraction(comman if !ok { // It's fine, no entry for execution registered. Register execution and step entry - manualController.InteractionStorage[interaction.ExecutionId] = map[string]manual.InteractionCommandData{ - interaction.StepId: interaction, + manualController.InteractionStorage[interaction.ExecutionId] = map[string]manual.InteractionStorageEntry{ + interaction.StepId: manual.InteractionStorageEntry{ + CommandData: interaction, + Channel: manualChan, + }, } return nil } @@ -172,7 +201,10 @@ func (manualController *InteractionController) registerPendingInteraction(comman // 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[interaction.StepId] = interaction + execution[interaction.StepId] = manual.InteractionStorageEntry{ + CommandData: interaction, + Channel: manualChan, + } return nil } @@ -181,27 +213,27 @@ func (manualController *InteractionController) getAllPendingInteractions() []man allPendingInteractions := []manual.InteractionCommandData{} for _, interactions := range manualController.InteractionStorage { for _, interaction := range interactions { - allPendingInteractions = append(allPendingInteractions, interaction) + allPendingInteractions = append(allPendingInteractions, interaction.CommandData) } } return allPendingInteractions } -func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionCommandData, error) { +func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionStorageEntry, error) { executionCommands, ok := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] if !ok { err := fmt.Errorf("no pending commands found for execution %s", commandMetadata.ExecutionId.String()) - return manual.InteractionCommandData{}, err + return manual.InteractionStorageEntry{}, err } - commandData, ok := executionCommands[commandMetadata.StepId] + interaction, ok := executionCommands[commandMetadata.StepId] if !ok { err := fmt.Errorf("no pending commands found for execution %s -> step %s", commandMetadata.ExecutionId.String(), commandMetadata.StepId, ) - return manual.InteractionCommandData{}, err + return manual.InteractionStorageEntry{}, err } - return commandData, nil + return interaction, nil } func (manualController *InteractionController) removeInteractionFromPending(commandMetadata execution.Metadata) error { diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 2b7a6b0a..443fd1f9 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -23,6 +23,11 @@ type InteractionCommandData struct { OutArgs 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 } +type InteractionStorageEntry struct { + CommandData InteractionCommandData + Channel chan InteractionResponse +} + // Object passed by the manual capability to the Interaction module type InteractionCommand struct { Metadata execution.Metadata From d378db290d61293460a87417f245d23101e5a492 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:08:01 +0100 Subject: [PATCH 26/63] update documentation to reflect idle vs async waits --- docs/content/en/docs/core-components/modules.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 564fada0..59f12df7 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -401,25 +401,27 @@ control "ThirdPartyManualIntegration" as 3ptool manual -> interaction : Queue(command, channel) +manual -> manual : idle wait on chan activate interaction interaction -> interaction : save manual command status alt Third Party Integration flow -interaction -> 3ptool : async Notify(interactionCommand, interactionChannel) +interaction ->> 3ptool : async Notify(interactionCommand, interactionChannel) activate 3ptool -interaction -> interaction : idle wait on chan +interaction ->> interaction : async wait on chan 3ptool <--> Integration : command posting and handling 3ptool -> 3ptool : post InteractionIntegrationResponse on channel -3ptool --> interaction +3ptool --> interaction : InteractionIntegrationResponse +interaction --> manual : InteractionResponse deactivate 3ptool else Native ManualAPI flow -interaction -> interaction : idle wait on chan +interaction ->> interaction : async wait on chan api -> interaction : GetPendingCommands() api -> interaction : GetPendingCommand(executionId, stepId) api -> interaction : Continue(InteractionResponse) +interaction --> manual : InteractionResponse end -interaction -> manual : manual command results deactivate interaction @enduml From 1c076c196b7a8c02a40cfa3d66618038ba0379a2 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:12:39 +0100 Subject: [PATCH 27/63] update documentation schemas with clearer channels and async explanation --- .../en/docs/core-components/modules.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 59f12df7..3eae54c1 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -358,9 +358,9 @@ interface ICapabilityInteraction{ } interface IInteracionStorage{ - GetPendingCommands() - GetPendingCommand() - Continue() + GetPendingCommands() []CommandData + GetPendingCommand(execution.metadata) CommandData + Continue(execution.metadata) StatusCode } interface IInteractionIntegrationNotifier { @@ -400,26 +400,26 @@ control "ManualAPI" as api control "ThirdPartyManualIntegration" as 3ptool -manual -> interaction : Queue(command, channel) -manual -> manual : idle wait on chan +manual -> interaction : Queue(command, capabilityChannel) +manual -> manual : idle wait on capabilityChannel activate interaction interaction -> interaction : save manual command status alt Third Party Integration flow -interaction ->> 3ptool : async Notify(interactionCommand, interactionChannel) +interaction ->> 3ptool : async Notify(interactionCommand, integrationChannel) activate 3ptool -interaction ->> interaction : async wait on chan +interaction ->> interaction : async wait on integrationChannel 3ptool <--> Integration : command posting and handling 3ptool -> 3ptool : post InteractionIntegrationResponse on channel -3ptool --> interaction : InteractionIntegrationResponse -interaction --> manual : InteractionResponse +3ptool --> interaction : integrationChannel <- InteractionIntegrationResponse +interaction --> manual : capabilityChannel <- InteractionResponse deactivate 3ptool else Native ManualAPI flow -interaction ->> interaction : async wait on chan +interaction ->> interaction : async wait on integrationChannel api -> interaction : GetPendingCommands() -api -> interaction : GetPendingCommand(executionId, stepId) +api -> interaction : GetPendingCommand(execution.metadata) api -> interaction : Continue(InteractionResponse) -interaction --> manual : InteractionResponse +interaction --> manual : capabilityChannel <- InteractionResponse end deactivate interaction From 072d206d5baeed87039bc0b43685d223444bb1b0 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:17:37 +0100 Subject: [PATCH 28/63] improve manual documentation further --- docs/content/en/docs/core-components/modules.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 3eae54c1..d0659770 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -332,11 +332,11 @@ The manual step should provide a timeout SOARCA will by default use a timeout of #### 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 expected to be filled. +1. A message, the `command` of a manual command, is posted *somewhere*, *somehow*, together with the variables expected to be filled. 2. The playbook execution stops, waiting for *something* to respond to the message with the variables values. 3. The variables are streamed inside the playbook execution and handled accordingly. -Because the *somewhere* and *somehow* for posting a message can vary, and the *something* that replies can vary too, SOARCA adopts a flexible architecture to accomodate different ways of manual *interactions*. Below a simplified view of the architecture. +Because the *somewhere* and *somehow* for posting a message can vary, and the *something* that replies can vary too, SOARCA adopts a flexible architecture to accomodate different ways of manual *interactions*. Below a view of the architecture. ```plantuml @startuml @@ -369,6 +369,7 @@ interface IInteractionIntegrationNotifier { class Interaction { notifiers []IInteractionIntegrationNotifier + storage map[executionId]map[stepId]InteractionStorageEntry } class ThirdPartyManualIntegration @@ -387,10 +388,10 @@ 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. +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 the way the manual interactions components work. +The diagram below displays in some detail the way the manual interactions components work. ```plantuml @startuml @@ -427,7 +428,7 @@ deactivate interaction @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. +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. #### Success and failure From a6befdbb13a5fc8a799a13643e9fb6c18827e3bb Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 16 Dec 2024 17:21:38 +0100 Subject: [PATCH 29/63] better unit interfaces and change returned outargs to cacao vars --- .../en/docs/core-components/modules.md | 2 +- .../manual/interaction/interaction.go | 104 ++++++++++++------ pkg/core/capability/manual/manual.go | 21 +--- pkg/core/capability/manual/manual_test.go | 16 ++- pkg/models/manual/manual.go | 10 +- .../mock_interaction/mock_interaction.go | 12 +- 6 files changed, 100 insertions(+), 65 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index d0659770..8b299de8 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -354,7 +354,7 @@ interface ICapability{ } interface ICapabilityInteraction{ - Queue(command InteractionCommand, channel chan InteractionResponse) + Queue(command InteractionCommand, manualComms ManualCapabilityCommunication) } interface IInteracionStorage{ diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 4db6de20..3235abe9 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -1,17 +1,20 @@ package interaction import ( - "context" "fmt" "net/http" "reflect" "soarca/internal/logger" + "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "soarca/pkg/models/manual" "github.com/google/uuid" ) +// TODO +// - write unit tests + type Empty struct{} var component = reflect.TypeOf(Empty{}).PkgPath() @@ -26,7 +29,7 @@ type IInteractionIntegrationNotifier interface { } type ICapabilityInteraction interface { - Queue(command manual.InteractionCommand, channel chan manual.InteractionResponse, ctx context.Context) error + Queue(command manual.InteractionCommand, manualComms manual.ManualCapabilityCommunication) error } type IInteractionStorage interface { @@ -52,9 +55,9 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // ############################################################################ // ICapabilityInteraction implementation // ############################################################################ -func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualCapabilityChannel chan manual.InteractionResponse, ctx context.Context) error { +func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualComms manual.ManualCapabilityCommunication) error { - err := manualController.registerPendingInteraction(command, manualCapabilityChannel) + err := manualController.registerPendingInteraction(command, manualComms.Channel) if err != nil { return err } @@ -63,48 +66,60 @@ func (manualController *InteractionController) Queue(command manual.InteractionC integrationCommand := manual.InteractionIntegrationCommand(command) // One response channel for all integrations - interactionChannel := make(chan manual.InteractionIntegrationResponse) - defer close(interactionChannel) + integrationChannel := make(chan manual.InteractionIntegrationResponse) for _, notifier := range manualController.Notifiers { - go notifier.Notify(integrationCommand, interactionChannel) + go notifier.Notify(integrationCommand, integrationChannel) } // Async idle wait on interaction integration channel - go manualController.waitInteractionIntegrationResponse(manualCapabilityChannel, ctx, interactionChannel) + go manualController.waitInteractionIntegrationResponse(manualComms, integrationChannel) return nil } -func (manualController *InteractionController) waitInteractionIntegrationResponse(manualCapabilityChannel chan manual.InteractionResponse, ctx context.Context, interactionChannel chan manual.InteractionIntegrationResponse) { - defer close(interactionChannel) +func (manualController *InteractionController) waitInteractionIntegrationResponse(manualComms manual.ManualCapabilityCommunication, integrationChannel chan manual.InteractionIntegrationResponse) { + defer close(integrationChannel) for { select { - case <-ctx.Done(): - log.Debug("context canceled due to timeout. exiting goroutine") + case <-manualComms.TimeoutContext.Done(): + log.Info("context canceled due to timeout. exiting goroutine") return - case result := <-interactionChannel: + case result := <-integrationChannel: // Check register for pending manual command - metadata := execution.Metadata{ - ExecutionId: uuid.MustParse(result.Payload.ExecutionId), - PlaybookId: result.Payload.PlaybookId, - StepId: result.Payload.StepId, + metadata, err := manualController.makeExecutionMetadataFromPayload(result.Payload) + if err != nil { + log.Error(err) + manualComms.Channel <- manual.InteractionResponse{ + ResponseError: err, + Payload: cacao.Variables{}, + } + return } - // Remove interaction from pending ones - err := manualController.removeInteractionFromPending(metadata) + err = manualController.removeInteractionFromPending(metadata) if err != nil { // If it was not there, was already resolved log.Warning(err) + // Captured if channel not yet closed log.Warning("manual command not found among pending ones. should be already resolved") + manualComms.Channel <- manual.InteractionResponse{ + ResponseError: err, + Payload: cacao.Variables{}, + } return } // Copy result and conversion back to interactionResponse format - interactionResponse := manual.InteractionResponse(result) + returnedVars := manualController.copyOutArgsToVars(result.Payload.ResponseOutArgs) - manualCapabilityChannel <- interactionResponse + interactionResponse := manual.InteractionResponse{ + ResponseError: result.ResponseError, + Payload: returnedVars, + } + + manualComms.Channel <- interactionResponse return } } @@ -125,13 +140,12 @@ func (manualController *InteractionController) GetPendingCommand(metadata execut return interaction.CommandData, http.StatusOK, err } -func (manualController *InteractionController) PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) { +func (manualController *InteractionController) PostContinue(result manual.ManualOutArgUpdatePayload) (int, error) { log.Trace("completing manual command") - metadata := execution.Metadata{ - ExecutionId: uuid.MustParse(outArgsResult.ExecutionId), - PlaybookId: outArgsResult.PlaybookId, - StepId: outArgsResult.StepId, + metadata, err := manualController.makeExecutionMetadataFromPayload(result) + if err != nil { + return http.StatusBadRequest, err } // TODO: determine status code @@ -145,9 +159,11 @@ func (manualController *InteractionController) PostContinue(outArgsResult manual // If it is, put outArgs back into manualCapabilityChannel + // Copy result and conversion back to interactionResponse format + returnedVars := manualController.copyOutArgsToVars(result.ResponseOutArgs) pendingEntry.Channel <- manual.InteractionResponse{ ResponseError: nil, - Payload: outArgsResult, + Payload: returnedVars, } // de-register the command err = manualController.removeInteractionFromPending(metadata) @@ -181,7 +197,7 @@ func (manualController *InteractionController) registerPendingInteraction(comman if !ok { // It's fine, no entry for execution registered. Register execution and step entry manualController.InteractionStorage[interaction.ExecutionId] = map[string]manual.InteractionStorageEntry{ - interaction.StepId: manual.InteractionStorageEntry{ + interaction.StepId: { CommandData: interaction, Channel: manualChan, }, @@ -254,10 +270,28 @@ func (manualController *InteractionController) removeInteractionFromPending(comm return nil } -// func (manualController *InteractionController) continueInteraction(interactionResponse manual.InteractionResponse) error { -// // TODO -// if interactionResponse.ResponseError != nil { -// return interactionResponse.ResponseError -// } -// return nil -// } +func (manualController *InteractionController) copyOutArgsToVars(outArgs manual.ManualOutArgs) cacao.Variables { + vars := cacao.NewVariables() + for name, outVar := range outArgs { + + vars[name] = cacao.Variable{ + Type: outVar.Type, + Name: outVar.Name, + Value: outVar.Value, + } + } + return vars +} + +func (manualController *InteractionController) makeExecutionMetadataFromPayload(payload manual.ManualOutArgUpdatePayload) (execution.Metadata, error) { + executionId, err := uuid.Parse(payload.ExecutionId) + if err != nil { + return execution.Metadata{}, err + } + metadata := execution.Metadata{ + ExecutionId: executionId, + PlaybookId: payload.PlaybookId, + StepId: payload.StepId, + } + return metadata, nil +} diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index 731c912f..24938dd4 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -55,7 +55,11 @@ func (manual *ManualCapability) Execute( channel := make(chan manualModel.InteractionResponse) defer close(channel) - err := manual.interaction.Queue(command, channel, ctx) + err := manual.interaction.Queue(command, manualModel.ManualCapabilityCommunication{ + Channel: channel, + TimeoutContext: ctx, + }) + if err != nil { return cacao.NewVariables(), err } @@ -78,26 +82,13 @@ func (manual *ManualCapability) awaitUserInput(channel chan manualModel.Interact return cacao.NewVariables(), err case response := <-channel: log.Trace("received response from api") - cacaoVars := manual.copyOutArgsToVars(response.Payload.ResponseOutArgs) + cacaoVars := response.Payload return cacaoVars, response.ResponseError } } } -func (manual *ManualCapability) copyOutArgsToVars(outArgs manualModel.ManualOutArgs) cacao.Variables { - vars := cacao.NewVariables() - for name, outVar := range outArgs { - - vars[name] = cacao.Variable{ - Type: outVar.Type, - Name: outVar.Name, - Value: outVar.Value, - } - } - return vars -} - func (manual *ManualCapability) getTimeoutValue(userTimeout int) time.Duration { if userTimeout == 0 { log.Warning("timeout is not set or set to 0 fallback timeout of 1 minute is used to complete step") diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index ff00fc93..4fc96c0e 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -2,6 +2,7 @@ package manual import ( "soarca/pkg/core/capability" + "soarca/pkg/models/cacao" "soarca/pkg/models/execution" manualModel "soarca/pkg/models/manual" "soarca/test/unittest/mocks/mock_interaction" @@ -15,7 +16,7 @@ import ( func TestManualExecution(t *testing.T) { interactionMock := mock_interaction.MockInteraction{} - var capturedChannel chan manualModel.InteractionResponse + var capturedComm manualModel.ManualCapabilityCommunication manual := New(&interactionMock) @@ -25,8 +26,9 @@ func TestManualExecution(t *testing.T) { command := manualModel.InteractionCommand{} // Capture the channel passed to Queue - interactionMock.On("Queue", command, mock_interaction.AnyChannel(), mock_interaction.AnyContext()).Return(nil).Run(func(args mock.Arguments) { - capturedChannel = args.Get(1).(chan manualModel.InteractionResponse) + + 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 @@ -42,12 +44,8 @@ func TestManualExecution(t *testing.T) { // Simulate the response after ensuring the channel is captured time.Sleep(100 * time.Millisecond) - capturedChannel <- manualModel.InteractionResponse{ - Payload: manualModel.ManualOutArgUpdatePayload{ - ResponseOutArgs: manualModel.ManualOutArgs{ - "example": {Value: "example_value"}, - }, - }, + capturedComm.Channel <- manualModel.InteractionResponse{ + Payload: cacao.NewVariables(), } // Wait for the Execute method to complete diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 443fd1f9..694f94eb 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -1,6 +1,7 @@ package manual import ( + "context" "soarca/pkg/core/capability" "soarca/pkg/models/cacao" "soarca/pkg/models/execution" @@ -39,8 +40,8 @@ type InteractionCommand struct { type ManualOutArg struct { Type string `bson:"type,omitempty" json:"type,omitempty" example:"string"` // Type of the variable should be OASIS variable-type-ov Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ - Value string `bson:"value" json:"value" validate:"required" example:"this is a value"` // The value of the that the variable will evaluate to Description string `bson:"description,omitempty" json:"description,omitempty" example:"some string"` // A description of the variable + Value string `bson:"value" json:"value" validate:"required" 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 External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external } @@ -61,7 +62,12 @@ type ManualOutArgUpdatePayload struct { // The object that the Interaction module presents back to the manual capability type InteractionResponse struct { ResponseError error - Payload ManualOutArgUpdatePayload + Payload cacao.Variables +} + +type ManualCapabilityCommunication struct { + Channel chan InteractionResponse + TimeoutContext context.Context } // ################################################################################ diff --git a/test/unittest/mocks/mock_interaction/mock_interaction.go b/test/unittest/mocks/mock_interaction/mock_interaction.go index c5f0096c..06bc9582 100644 --- a/test/unittest/mocks/mock_interaction/mock_interaction.go +++ b/test/unittest/mocks/mock_interaction/mock_interaction.go @@ -12,9 +12,8 @@ type MockInteraction struct { } func (mock *MockInteraction) Queue(command manual.InteractionCommand, - channel chan manual.InteractionResponse, - ctx context.Context) error { - args := mock.Called(command, channel, ctx) + manualComms manual.ManualCapabilityCommunication) error { + args := mock.Called(command, manualComms) return args.Error(0) } @@ -31,3 +30,10 @@ func AnyChannel() interface{} { return true }) } + +// Custom matcher for any ManualCapabilityCommunication +func AnyManualCapabilityCommunication() interface{} { + return mock.MatchedBy(func(comm manual.ManualCapabilityCommunication) bool { + return true + }) +} From f041f5e8d6c263d44cabf0bec52e93983bd44985 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 20 Dec 2024 10:36:35 +0100 Subject: [PATCH 30/63] connect manual api to soarca initialization --- internal/controller/controller.go | 12 +++++ pkg/api/api.go | 20 +++++++ pkg/api/manual/manual_api.go | 53 ++++++++++++++----- .../manual/interaction/interaction.go | 2 +- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 22f8d060..33542628 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -11,6 +11,7 @@ import ( 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" @@ -64,6 +65,9 @@ var mainCache = cache.Cache{} const defaultCacheSize int = 10 +// One interaction per SOARCA instance +var mainInteraction = interaction.New(initializeManualIntegration()) + 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 } + // TODO: create interaction object + err = routes.Manual(app, mainInteraction) + routes.Logging(app) routes.Swagger(app) @@ -274,6 +281,11 @@ func (controller *Controller) setupAndRunMqtt() error { return nil } +func initializeManualIntegration() []interaction.IInteractionIntegrationNotifier { + // Manual interaction integrations will be initialized here when implemented + 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..ac948f26 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,14 @@ func Reporter(app *gin.Engine, informer informer.IExecutionInformer) error { return nil } +func Manual(app *gin.Engine, interaction interaction.IInteractionStorage) error { + log.Trace("Setting up manual routes") + manualHandler := manual_handler.NewManualHandler(interaction) + ManualRoutes(app, manualHandler) + + return nil +} + func Api(app *gin.Engine, controller decomposer_controller.IController, database database.IController, @@ -126,3 +137,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(":execution_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 index d8ba1381..c787bd5f 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -1,6 +1,8 @@ package manual import ( + "encoding/json" + "io" "net/http" "reflect" "soarca/internal/logger" @@ -48,6 +50,12 @@ type ManualHandler struct { interactionCapability interaction.IInteractionStorage } +func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandler { + instance := ManualHandler{} + instance.interactionCapability = interaction + return &instance +} + // manual // // @Summary get all pending manual commands that still needs values to be returned @@ -127,18 +135,39 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @Param response_out_args body manual.ManualOutArgs true "out args" // @Success 200 {object} api.Execution // @failure 400 {object} api.Error -// @Router /manual/continue/ [POST] +// @Router /manual/continue/{execution_id}/{step_id} [POST] func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { - execution_id := g.Param("execution_id") - playbook_id := g.Param("playbook_id") - step_id := g.Param("step_id") - outArgsUpdate := manual.ManualOutArgUpdatePayload{ - Type: g.Param("type"), - ExecutionId: execution_id, - PlaybookId: playbook_id, - StepId: step_id, + + paramExecutionId := g.Param("execution_id") + paramStepId := g.Param("step_id") + + 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/{execution_id}/{step_id}", "") + return } - status, err := manualHandler.interactionCapability.Continue(outArgsUpdate) + + var outArgsUpdate manual.ManualOutArgUpdatePayload + err = json.Unmarshal(jsonData, &outArgsUpdate) + if err != nil { + log.Error("failed to unmarshal JSON") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to unmarshal JSON", + "POST /manual/continue/{execution_id}/{step_id}", "") + return + } + + if (outArgsUpdate.ExecutionId != paramExecutionId) || (outArgsUpdate.StepId != paramStepId) { + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Mismatch between execution ID and step ID between URL parameters and request body", + "POST /manual/continue/{execution_id}/{step_id}", "") + return + } + + status, err := manualHandler.interactionCapability.PostContinue(outArgsUpdate) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -149,7 +178,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { g.JSON( status, api.Execution{ - ExecutionId: uuid.MustParse(execution_id), - PlaybookId: playbook_id, + ExecutionId: uuid.MustParse(outArgsUpdate.ExecutionId), + PlaybookId: outArgsUpdate.PlaybookId, }) } diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 3235abe9..299fc863 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -36,7 +36,7 @@ type IInteractionStorage interface { GetPendingCommands() ([]manual.InteractionCommandData, int, 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.InteractionCommandData, int, error) - Continue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) + PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) } type InteractionController struct { From 1eaba9d23913b7ba98b618a5166432827ffc1123 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 20 Dec 2024 10:52:08 +0100 Subject: [PATCH 31/63] modify API calls as I think they're better now --- .../en/docs/core-components/api-manual.md | 8 +++++--- pkg/api/api.go | 4 ++-- pkg/api/manual/manual_api.go | 18 ++++++++++-------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 6800056b..7dcba4f2 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -17,7 +17,8 @@ We will use HTTP status codes https://en.wikipedia.org/wiki/List_of_HTTP_status_ @startuml protocol Manual { GET /manual - POST /manual/continue + GET /manual/{execution-id}/{step-id} + PATCH /manual/{execution-id}/{step-id} } @enduml ``` @@ -153,7 +154,7 @@ None 404/Not found with payload: General error -#### POST `/manual/continue` +#### PATCH `/manual//` 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 @@ -192,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/pkg/api/api.go b/pkg/api/api.go index ac948f26..0a98bfcf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -142,7 +142,7 @@ func ManualRoutes(route *gin.Engine, manualHandler *manual_handler.ManualHandler manualRoutes := route.Group("/manual") { manualRoutes.GET("./", manualHandler.GetPendingCommands) - manualRoutes.GET(":execution_id/:step_id", manualHandler.GetPendingCommand) - manualRoutes.POST("/continue", manualHandler.PostContinue) + manualRoutes.GET(":exec_id/:step_id", manualHandler.GetPendingCommand) + manualRoutes.PATCH(":exec_id/:step_id", manualHandler.PatchContinue) } } diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index c787bd5f..7e6c1fdd 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -88,11 +88,11 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { // @Tags manual // @Accept json // @Produce json -// @Param execution_id path string true "execution ID" -// @Param step_id path string true "step ID" +// @Param exec_id path string true "execution ID" +// @Param step_id path string true "step ID" // @Success 200 {object} manual.InteractionCommandData // @failure 400 {object} api.Error -// @Router /manual/{execution_id}/{step_id} [GET] +// @Router /manual/{exec_id}/{step_id} [GET] func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { execution_id := g.Param("execution_id") step_id := g.Param("step_id") @@ -126,6 +126,8 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @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" @@ -135,10 +137,10 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @Param response_out_args body manual.ManualOutArgs true "out args" // @Success 200 {object} api.Execution // @failure 400 {object} api.Error -// @Router /manual/continue/{execution_id}/{step_id} [POST] -func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { +// @Router /manual/{exec_id}/{step_id} [PATCH] +func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { - paramExecutionId := g.Param("execution_id") + paramExecutionId := g.Param("exec_id") paramStepId := g.Param("step_id") jsonData, err := io.ReadAll(g.Request.Body) @@ -146,7 +148,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { log.Error("failed") apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to read json", - "POST /manual/continue/{execution_id}/{step_id}", "") + "POST /manual/continue/{exec_id}/{step_id}", "") return } @@ -156,7 +158,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { log.Error("failed to unmarshal JSON") apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to unmarshal JSON", - "POST /manual/continue/{execution_id}/{step_id}", "") + "POST /manual/continue/{exec_id}/{step_id}", "") return } From a6868d660f16d22efcda77b4f6af151c981f6f1e Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:15:16 +0100 Subject: [PATCH 32/63] first interaction tests --- .../manual/interaction/interaction.go | 5 + .../manual/interaction/interaction_test.go | 131 +++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 299fc863..60b84517 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -1,6 +1,7 @@ package interaction import ( + "errors" "fmt" "net/http" "reflect" @@ -62,6 +63,10 @@ func (manualController *InteractionController) Queue(command manual.InteractionC 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) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 2456d153..c3b746e2 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -1,9 +1,138 @@ package interaction import ( + "context" + "errors" + "soarca/pkg/core/capability" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + manualModel "soarca/pkg/models/manual" "testing" + "time" + + "github.com/go-playground/assert/v2" + "github.com/google/uuid" ) -func TestSAreHardToImplement(t *testing.T) { +func TestQueuSimple(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.Fail() + } + + // Fetch pending command + retrievedCommand, err := interaction.getPendingInteraction(testMetadata) + if err != nil { + t.Fail() + } + + assert.Equal(t, + retrievedCommand.CommandData.ExecutionId, + testInteractionCommand.Metadata.ExecutionId.String(), + ) + assert.Equal(t, + retrievedCommand.CommandData.PlaybookId, + testInteractionCommand.Metadata.PlaybookId, + ) + assert.Equal(t, + retrievedCommand.CommandData.StepId, + testInteractionCommand.Metadata.StepId, + ) + assert.Equal(t, + retrievedCommand.CommandData.Command, + testInteractionCommand.Context.Command.Command, + ) + +} + +func TestQueueFailWithoutTimeout(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + + testCommand := manualModel.InteractionCommand{} + + 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")) + +} + +// ############################################################################ +// Utils +// ############################################################################ + +var testUUIDStr string = "61a6c41e-6efc-4516-a242-dfbc5c89d562" +var testMetadata = execution.Metadata{ + ExecutionId: uuid.MustParse(testUUIDStr), + PlaybookId: "dummy_playbook_id", + StepId: "dummy_step_id", +} +var testInteractionCommand = manualModel.InteractionCommand{ + Metadata: testMetadata, + Context: capability.Context{ + Command: cacao.Command{ + Type: "dummy_type", + Command: "dummy_command", + Description: "dummy_description", + CommandB64: "dummy_command_b64", + Version: "1.0", + PlaybookActivity: "dummy_activity", + Headers: cacao.Headers{}, + Content: "dummy_content", + ContentB64: "dummy_content_b64", + }, + Step: cacao.Step{ + Type: "dummy_type", + ID: "dummy_id", + Name: "dummy_name", + Description: "dummy_description", + Timeout: 1, + StepVariables: cacao.Variables{ + "var1": { + Type: "string", + Name: "var1", + Description: "dummy variable", + Value: "dummy_value", + Constant: false, + External: false, + }, + }, + Commands: []cacao.Command{ + { + Type: "dummy_type", + Command: "dummy_command", + }, + }, + }, + Authentication: cacao.AuthenticationInformation{}, + Target: cacao.AgentTarget{ + ID: "dummy_id", + Type: "dummy_type", + Name: "dummy_name", + Description: "dummy_description", + }, + Variables: cacao.Variables{ + "var1": { + Type: "string", + Name: "var1", + Description: "dummy variable", + Value: "dummy_value", + Constant: false, + External: false, + }, + }, + }, } From 8794bbd2378ad26fa286e4afcb956b2767247448 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:45:52 +0100 Subject: [PATCH 33/63] add more interaction tests but more to go --- .../manual/interaction/interaction_test.go | 177 ++++++++++++++---- 1 file changed, 145 insertions(+), 32 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index c3b746e2..a2fbefa5 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -14,7 +14,7 @@ import ( "github.com/google/uuid" ) -func TestQueuSimple(t *testing.T) { +func TestQueue(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer testCancel() @@ -29,44 +29,157 @@ func TestQueuSimple(t *testing.T) { if err != nil { t.Fail() } +} + +func TestQueueFailWithoutTimeout(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + + testCommand := manualModel.InteractionCommand{} + + 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")) +} - // Fetch pending command +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.Fail() + } retrievedCommand, err := interaction.getPendingInteraction(testMetadata) if err != nil { t.Fail() } + //Channel + assert.Equal(t, + retrievedCommand.Channel, + testChan, + ) + + // Type + assert.Equal(t, + retrievedCommand.CommandData.Type, + testInteractionCommand.Context.Command.Type, + ) + // ExecutionId assert.Equal(t, retrievedCommand.CommandData.ExecutionId, testInteractionCommand.Metadata.ExecutionId.String(), ) + // PlaybookId assert.Equal(t, retrievedCommand.CommandData.PlaybookId, testInteractionCommand.Metadata.PlaybookId, ) + // StepId assert.Equal(t, retrievedCommand.CommandData.StepId, testInteractionCommand.Metadata.StepId, ) + // Description + assert.Equal(t, + retrievedCommand.CommandData.Description, + testInteractionCommand.Context.Command.Description, + ) + // Command assert.Equal(t, retrievedCommand.CommandData.Command, testInteractionCommand.Context.Command.Command, ) + // CommandB64 + assert.Equal(t, + retrievedCommand.CommandData.CommandBase64, + testInteractionCommand.Context.Command.CommandB64, + ) + // Target + assert.Equal(t, + retrievedCommand.CommandData.Target, + testInteractionCommand.Context.Target, + ) + // OutArgs + assert.Equal(t, + retrievedCommand.CommandData.OutArgs, + testInteractionCommand.Context.Variables, + ) +} + +func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { + interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Fail() + } + + testNewInteractionCommandSecond := testInteractionCommand + newStepId2 := "test_second_step_id" + testNewInteractionCommandSecond.Metadata.StepId = newStepId2 + + testNewInteractionCommandThird := testInteractionCommand + newStepId3 := "test_third_step_id" + testNewInteractionCommandThird.Metadata.StepId = newStepId3 + + err = interaction.registerPendingInteraction(testNewInteractionCommandSecond, testChan) + if err != nil { + t.Fail() + } + err = interaction.registerPendingInteraction(testNewInteractionCommandThird, testChan) + if err != nil { + t.Fail() + } } -func TestQueueFailWithoutTimeout(t *testing.T) { +func TestRegisterRetrieveExistingExecutionNewPendingInteraction(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) + testChan := make(chan manualModel.InteractionResponse) + defer close(testChan) - testCommand := manualModel.InteractionCommand{} + err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err != nil { + t.Fail() + } - testCapComms := manualModel.ManualCapabilityCommunication{ - Channel: make(chan manualModel.InteractionResponse), - TimeoutContext: context.WithoutCancel(context.Background()), + testNewInteractionCommand := testInteractionCommand + newExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" + testNewInteractionCommand.Metadata.ExecutionId = uuid.MustParse(newExecId) + + err = interaction.registerPendingInteraction(testNewInteractionCommand, testChan) + if err != nil { + t.Fail() } - err := interaction.Queue(testCommand, testCapComms) - assert.Equal(t, err, errors.New("manual command does not have a deadline")) +} + +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.Fail() + } + + err = interaction.registerPendingInteraction(testInteractionCommand, testChan) + if err == nil { + 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) } // ############################################################################ @@ -76,60 +189,60 @@ func TestQueueFailWithoutTimeout(t *testing.T) { var testUUIDStr string = "61a6c41e-6efc-4516-a242-dfbc5c89d562" var testMetadata = execution.Metadata{ ExecutionId: uuid.MustParse(testUUIDStr), - PlaybookId: "dummy_playbook_id", - StepId: "dummy_step_id", + PlaybookId: "test_playbook_id", + StepId: "test_step_id", } var testInteractionCommand = manualModel.InteractionCommand{ Metadata: testMetadata, Context: capability.Context{ Command: cacao.Command{ - Type: "dummy_type", - Command: "dummy_command", - Description: "dummy_description", - CommandB64: "dummy_command_b64", + Type: "test_type", + Command: "test_command", + Description: "test_description", + CommandB64: "test_command_b64", Version: "1.0", - PlaybookActivity: "dummy_activity", + PlaybookActivity: "test_activity", Headers: cacao.Headers{}, - Content: "dummy_content", - ContentB64: "dummy_content_b64", + Content: "test_content", + ContentB64: "test_content_b64", }, Step: cacao.Step{ - Type: "dummy_type", - ID: "dummy_id", - Name: "dummy_name", - Description: "dummy_description", + Type: "test_type", + ID: "test_id", + Name: "test_name", + Description: "test_description", Timeout: 1, StepVariables: cacao.Variables{ "var1": { Type: "string", Name: "var1", - Description: "dummy variable", - Value: "dummy_value", + Description: "test variable", + Value: "test_value", Constant: false, External: false, }, }, Commands: []cacao.Command{ { - Type: "dummy_type", - Command: "dummy_command", + Type: "test_type", + Command: "test_command", }, }, }, Authentication: cacao.AuthenticationInformation{}, Target: cacao.AgentTarget{ - ID: "dummy_id", - Type: "dummy_type", - Name: "dummy_name", - Description: "dummy_description", + ID: "test_id", + Type: "test_type", + Name: "test_name", + Description: "test_description", }, Variables: cacao.Variables{ "var1": { Type: "string", Name: "var1", - Description: "dummy variable", - Value: "dummy_value", + Description: "test variable", + Value: "test_value", Constant: false, External: false, }, From 0dfabb12754ae6f490b7f74f28e5240b6808bf49 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:59:35 +0100 Subject: [PATCH 34/63] add more test and note that manual outargs is broken atm --- .../manual/interaction/interaction.go | 15 +- .../manual/interaction/interaction_test.go | 181 +++++++++++++++++- 2 files changed, 188 insertions(+), 8 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 60b84517..a6093a2e 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -14,7 +14,7 @@ import ( ) // TODO -// - write unit tests +// Add manual capability to action execution, and solve outArgs inconsistencies... type Empty struct{} @@ -153,17 +153,22 @@ func (manualController *InteractionController) PostContinue(result manual.Manual return http.StatusBadRequest, err } - // TODO: determine status code - - // If not, it means it was already solved (right?) + // If not in there, it means it was already solved (right?) pendingEntry, err := manualController.getPendingInteraction(metadata) if err != nil { log.Warning(err) return http.StatusAlreadyReported, err } - // If it is, put outArgs back into manualCapabilityChannel + // If it is, first check that out args provided match the variables + for varName := range result.ResponseOutArgs { + if _, ok := pendingEntry.CommandData.OutArgs[varName]; !ok { + log.Warning("provided out args do not match command-related variables") + return http.StatusBadRequest, err + } + } + //Then put outArgs back into manualCapabilityChannel // Copy result and conversion back to interactionResponse format returnedVars := manualController.copyOutArgsToVars(result.ResponseOutArgs) pendingEntry.Channel <- manual.InteractionResponse{ diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index a2fbefa5..f3d23b3a 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -7,11 +7,13 @@ import ( "soarca/pkg/models/cacao" "soarca/pkg/models/execution" manualModel "soarca/pkg/models/manual" + "strings" "testing" "time" "github.com/go-playground/assert/v2" "github.com/google/uuid" + "github.com/sirupsen/logrus" ) func TestQueue(t *testing.T) { @@ -27,6 +29,7 @@ func TestQueue(t *testing.T) { // Call queue err := interaction.Queue(testInteractionCommand, testCapComms) if err != nil { + t.Log(err) t.Fail() } } @@ -44,6 +47,35 @@ func TestQueueFailWithoutTimeout(t *testing.T) { 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() + } + + // Call queue + time.Sleep(50 * time.Millisecond) + + expectedLogEntry := "context canceled due to timeout. exiting goroutine" + assert.NotEqual(t, len(hook.Entries), 0) + assert.Equal(t, strings.Contains(hook.Entries[0].Message, expectedLogEntry), true) + +} + func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testChan := make(chan manualModel.InteractionResponse) @@ -51,10 +83,12 @@ func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { 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() } @@ -118,6 +152,7 @@ func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { err := interaction.registerPendingInteraction(testInteractionCommand, testChan) if err != nil { + t.Log(err) t.Fail() } @@ -131,12 +166,37 @@ func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { 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 TestPostContinue(t *testing.T) { + + // TODO + + 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() } + + //val, err := interaction.PostContinue() } func TestRegisterRetrieveExistingExecutionNewPendingInteraction(t *testing.T) { @@ -146,6 +206,7 @@ func TestRegisterRetrieveExistingExecutionNewPendingInteraction(t *testing.T) { err := interaction.registerPendingInteraction(testInteractionCommand, testChan) if err != nil { + t.Log(err) t.Fail() } @@ -155,6 +216,7 @@ func TestRegisterRetrieveExistingExecutionNewPendingInteraction(t *testing.T) { err = interaction.registerPendingInteraction(testNewInteractionCommand, testChan) if err != nil { + t.Log(err) t.Fail() } } @@ -166,11 +228,13 @@ func TestFailOnRegisterSamePendingInteraction(t *testing.T) { 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() } @@ -182,10 +246,121 @@ func TestFailOnRegisterSamePendingInteraction(t *testing.T) { 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 := errors.New( + "no pending commands found for execution 50b6d52c-6efc-4516-a242-dfbc5c89d421", + ) + assert.Equal(t, err, expectedErr) +} + +func TestFailOnRetrieveUnexistingCommandInteraction(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 := errors.New( + "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.CommandData.ExecutionId, + testInteractionCommand.Metadata.ExecutionId.String(), + ) + assert.Equal(t, + pendingCommand.CommandData.StepId, + testInteractionCommand.Metadata.StepId, + ) + + err = interaction.removeInteractionFromPending(testMetadata) + if err != nil { + t.Log(err) + t.Fail() + } + + _, err = interaction.getPendingInteraction(testMetadata) + if err == nil { + t.Log(err) + t.Fail() + } + + expectedErr := errors.New( + "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), @@ -218,7 +393,7 @@ var testInteractionCommand = manualModel.InteractionCommand{ Type: "string", Name: "var1", Description: "test variable", - Value: "test_value", + Value: "test_value_1", Constant: false, External: false, }, @@ -240,9 +415,9 @@ var testInteractionCommand = manualModel.InteractionCommand{ Variables: cacao.Variables{ "var1": { Type: "string", - Name: "var1", + Name: "var2", Description: "test variable", - Value: "test_value", + Value: "test_value_2", Constant: false, External: false, }, From 4317bd2cd55a5939ef1afa246fd82dab4d366e9d Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 23 Dec 2024 11:34:58 +0100 Subject: [PATCH 35/63] clean outargs management --- .../manual/interaction/interaction.go | 23 ++++++++++++++++--- .../manual/interaction/interaction_test.go | 5 ++-- pkg/models/cacao/cacao.go | 4 +++- pkg/models/manual/manual.go | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index a6093a2e..dbad3988 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -14,7 +14,24 @@ import ( ) // TODO -// Add manual capability to action execution, and solve outArgs inconsistencies... +// Add manual capability to action execution, + +// NOTE: current outArgs management for Manual commands: +// - The decomposer passes the PlaybookStepMetadata object to the +// action executor, which includes Step +// - The action executor calls Execute on the capability (command type) +// passing capability.Context, +// - which includes the Step object +// - The manual capability calls Queue passing InteractionCommand, +// which includes capability.Context +// - Queue() posts a message, which shall include the text of the manual command, +// and the varibales (outArgs) expected +// - registerPendingInteraction records the CACAO Variables corresponding to the +// outArgs field (in the step. In future, in the command) +// - A manual response posts back a map[string]manual.ManualOutArg object, +// which is exactly like cacao variables, but with different requested fields. +// - The Interaction object cleans the returned variables to only keep +// the name, type, and value (to not overwrite other fields) type Empty struct{} @@ -162,7 +179,7 @@ func (manualController *InteractionController) PostContinue(result manual.Manual // If it is, first check that out args provided match the variables for varName := range result.ResponseOutArgs { - if _, ok := pendingEntry.CommandData.OutArgs[varName]; !ok { + if _, ok := pendingEntry.CommandData.OutVariables[varName]; !ok { log.Warning("provided out args do not match command-related variables") return http.StatusBadRequest, err } @@ -199,7 +216,7 @@ func (manualController *InteractionController) registerPendingInteraction(comman Command: command.Context.Command.Command, CommandBase64: command.Context.Command.CommandB64, Target: command.Context.Target, - OutArgs: command.Context.Variables, + OutVariables: command.Context.Variables.Select(command.Context.Step.OutArgs), } execution, ok := manualController.InteractionStorage[interaction.ExecutionId] diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index f3d23b3a..34eaa5fd 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -140,7 +140,7 @@ func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { ) // OutArgs assert.Equal(t, - retrievedCommand.CommandData.OutArgs, + retrievedCommand.CommandData.OutVariables, testInteractionCommand.Context.Variables, ) } @@ -398,6 +398,7 @@ var testInteractionCommand = manualModel.InteractionCommand{ External: false, }, }, + OutArgs: cacao.OutArgs{"var2"}, Commands: []cacao.Command{ { Type: "test_type", @@ -413,7 +414,7 @@ var testInteractionCommand = manualModel.InteractionCommand{ Description: "test_description", }, Variables: cacao.Variables{ - "var1": { + "var2": { Type: "string", Name: "var2", Description: "test variable", diff --git a/pkg/models/cacao/cacao.go b/pkg/models/cacao/cacao.go index 74b781c4..a3c2b382 100644 --- a/pkg/models/cacao/cacao.go +++ b/pkg/models/cacao/cacao.go @@ -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 index 694f94eb..ffde2899 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -21,7 +21,7 @@ type InteractionCommandData struct { Command string `bson:"command" json:"command" validate:"required"` // The command for the agent either command CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present Target cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command - OutArgs 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 + 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 } type InteractionStorageEntry struct { From 4485ffc6d0b921167f00f90878990d6cdee50cd5 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:11:08 +0100 Subject: [PATCH 36/63] add more test also fixed goroutine exit pending bug --- .../manual/interaction/interaction.go | 27 ++- .../manual/interaction/interaction_test.go | 218 +++++++++++++++++- 2 files changed, 234 insertions(+), 11 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index dbad3988..9a24fb8e 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -105,7 +105,11 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons for { select { case <-manualComms.TimeoutContext.Done(): - log.Info("context canceled due to timeout. exiting goroutine") + log.Info("context canceled due to response or timeout. exiting goroutine") + return + + case <-manualComms.Channel: + log.Info("detected activity on manual capability channel. exiting goroutine without consuming the message") return case result := <-integrationChannel: @@ -177,17 +181,34 @@ func (manualController *InteractionController) PostContinue(result manual.Manual return http.StatusAlreadyReported, err } - // If it is, first check that out args provided match the variables - for varName := range result.ResponseOutArgs { + // If it is + for varName, variable := range result.ResponseOutArgs { + // first check that out args provided match the variables if _, ok := pendingEntry.CommandData.OutVariables[varName]; !ok { log.Warning("provided out args do not match command-related variables") return http.StatusBadRequest, err } + // then warn if any value outside "value" has changed + if pending, ok := pendingEntry.CommandData.OutVariables[varName]; ok { + if variable.Constant != pending.Constant { + log.Warningf("provided out arg %s is attempting to change 'Constant' property", varName) + } + if variable.Description != pending.Description { + log.Warningf("provided out arg %s is attempting to change 'Description' property", varName) + } + if variable.External != pending.External { + log.Warningf("provided out arg %s is attempting to change 'External' property", varName) + } + if variable.Type != pending.Type { + log.Warningf("provided out arg %s is attempting to change 'Type' property", varName) + } + } } //Then put outArgs back into manualCapabilityChannel // Copy result and conversion back to interactionResponse format returnedVars := manualController.copyOutArgsToVars(result.ResponseOutArgs) + log.Info("putting stuff in manual capability channel") pendingEntry.Channel <- manual.InteractionResponse{ ResponseError: nil, Payload: returnedVars, diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 34eaa5fd..90d37817 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -2,7 +2,10 @@ package interaction import ( "context" + "encoding/json" "errors" + "fmt" + "reflect" "soarca/pkg/core/capability" "soarca/pkg/models/cacao" "soarca/pkg/models/execution" @@ -67,10 +70,9 @@ func TestQueueExitOnTimeout(t *testing.T) { t.Fail() } - // Call queue time.Sleep(50 * time.Millisecond) - expectedLogEntry := "context canceled due to timeout. exiting goroutine" + expectedLogEntry := "context canceled due to response or timeout. exiting goroutine" assert.NotEqual(t, len(hook.Entries), 0) assert.Equal(t, strings.Contains(hook.Entries[0].Message, expectedLogEntry), true) @@ -145,6 +147,99 @@ func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { ) } +func TestGetAllPendingInteractions(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() + } + + expectedInteractionsJson := ` +[ + { + "type": "test_type", + "execution_id": "61a6c41e-6efc-4516-a242-dfbc5c89d562", + "playbook_id": "test_playbook_id", + "step_id": "test_step_id", + "description": "test_description", + "command": "test_command", + "commandb64": "test_command_b64", + "targets": { + "id": "test_id", + "type": "test_type", + "name": "test_name", + "description": "test_description", + "location": {}, + "contact": {} + }, + "out_args": { + "var2": { + "type": "string", + "name": "var2", + "description": "test variable", + "value": "test_value_2" + } + } + }, + { + "type": "test_type", + "execution_id": "50b6d52c-6efc-4516-a242-dfbc5c89d421", + "playbook_id": "test_playbook_id", + "step_id": "test_step_id", + "description": "test_description", + "command": "test_command", + "commandb64": "test_command_b64", + "targets": { + "id": "test_id", + "type": "test_type", + "name": "test_name", + "description": "test_description", + "location": {}, + "contact": {} + }, + "out_args": { + "var2": { + "type": "string", + "name": "var2", + "description": "test variable", + "value": "test_value_2" + } + } + } +] + ` + var expectedInteractions []manualModel.InteractionCommandData + err = json.Unmarshal([]byte(expectedInteractionsJson), &expectedInteractions) + if err != nil { + t.Log(err) + t.Fail() + } + t.Log("expected interactions") + t.Log(expectedInteractions) + + receivedInteractions := interaction.getAllPendingInteractions() + + if !reflect.DeepEqual(expectedInteractions, receivedInteractions) { + err = fmt.Errorf("expected %v, but got %v", expectedInteractions, receivedInteractions) + t.Log(err) + t.Fail() + } +} + func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testChan := make(chan manualModel.InteractionResponse) @@ -176,9 +271,7 @@ func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { } } -func TestPostContinue(t *testing.T) { - - // TODO +func TestCopyOutArgsToVars(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Millisecond) @@ -196,10 +289,119 @@ func TestPostContinue(t *testing.T) { t.Fail() } - //val, err := interaction.PostContinue() + outArg := manualModel.ManualOutArg{ + 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 := manualModel.ManualOutArgs{"var2": outArg} + + vars := interaction.copyOutArgsToVars(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 := 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() + } + + pending, err := interaction.getPendingInteraction((testMetadata)) + if err != nil { + t.Log(err) + t.Fail() + } + fmt.Println(pending) + + outArg := manualModel.ManualOutArg{ + 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 + } + + outArgsUpdate := manualModel.ManualOutArgUpdatePayload{ + Type: "test-manual-response", + ExecutionId: testMetadata.ExecutionId.String(), + PlaybookId: testMetadata.PlaybookId, + StepId: testMetadata.StepId, + ResponseStatus: true, + ResponseOutArgs: manualModel.ManualOutArgs{"var2": outArg}, + } + + statusCode, err := interaction.PostContinue(outArgsUpdate) + + expectedStatusCode := 200 + var expectedErr error + + assert.Equal(t, statusCode, expectedStatusCode) + assert.Equal(t, err, expectedErr) + + expectedLogEntry1 := "provided out arg var2 is attempting to change 'Constant' property" + expectedLogEntry2 := "provided out arg var2 is attempting to change 'Description' property" + expectedLogEntry3 := "provided out arg var2 is attempting to change 'External' property" + expectedLogs := []string{expectedLogEntry1, expectedLogEntry2, expectedLogEntry3} + + all := true + for _, expectedMessage := range expectedLogs { + containsAll := true + for _, entry := range hook.Entries { + if strings.Contains(expectedMessage, entry.Message) { + containsAll = true + break + } + if !strings.Contains(expectedMessage, entry.Message) { + 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 TestRegisterRetrieveExistingExecutionNewPendingInteraction(t *testing.T) { +func TestPostContinueFailOnUnmatchedOutArgs(t *testing.T) { + +} + +func TestRegisterRetrieveNewExecutionNewPendingInteraction(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testChan := make(chan manualModel.InteractionResponse) defer close(testChan) @@ -327,7 +529,7 @@ func TestRemovePendingInteraciton(t *testing.T) { t.Fail() } - _, err = interaction.getPendingInteraction(testMetadata) + err = interaction.removeInteractionFromPending(testMetadata) if err == nil { t.Log(err) t.Fail() From c37faa4a466c2f5ed31e578c29069ef35ab45fc6 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:52:51 +0100 Subject: [PATCH 37/63] add manual api calls testing --- internal/controller/controller.go | 13 +- pkg/api/api.go | 6 +- pkg/api/manual/manual_api.go | 15 +- .../manual/interaction/interaction.go | 9 +- .../manual/interaction/interaction_test.go | 4 +- pkg/models/manual/manual.go | 4 +- .../api/routes/manual_api/manual_api_test.go | 129 ++++++++++++++++++ .../mock_interaction_storage.go | 27 ++++ 8 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 33542628..b5840cc5 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -65,8 +65,8 @@ var mainCache = cache.Cache{} const defaultCacheSize int = 10 -// One interaction per SOARCA instance -var mainInteraction = interaction.New(initializeManualIntegration()) +// One manual interaction per SOARCA instance +var mainInteraction = interaction.New(registerManualIntegration()) func (controller *Controller) NewDecomposer() decomposer.IDecomposer { ssh := new(ssh.SshCapability) @@ -258,8 +258,8 @@ func initializeCore(app *gin.Engine) error { return err } - // TODO: create interaction object - err = routes.Manual(app, mainInteraction) + // Manual capability native routes + routes.Manual(app, mainInteraction) routes.Logging(app) routes.Swagger(app) @@ -281,8 +281,11 @@ func (controller *Controller) setupAndRunMqtt() error { return nil } -func initializeManualIntegration() []interaction.IInteractionIntegrationNotifier { +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{} } diff --git a/pkg/api/api.go b/pkg/api/api.go index 0a98bfcf..7481861e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -48,12 +48,10 @@ func Reporter(app *gin.Engine, informer informer.IExecutionInformer) error { return nil } -func Manual(app *gin.Engine, interaction interaction.IInteractionStorage) error { +func Manual(app *gin.Engine, interaction interaction.IInteractionStorage) { log.Trace("Setting up manual routes") manualHandler := manual_handler.NewManualHandler(interaction) ManualRoutes(app, manualHandler) - - return nil } func Api(app *gin.Engine, @@ -141,7 +139,7 @@ func TriggerRoutes(route *gin.Engine, triggerHandler *trigger_handler.TriggerHan func ManualRoutes(route *gin.Engine, manualHandler *manual_handler.ManualHandler) { manualRoutes := route.Group("/manual") { - manualRoutes.GET("./", manualHandler.GetPendingCommands) + manualRoutes.GET("/", manualHandler.GetPendingCommands) manualRoutes.GET(":exec_id/:step_id", manualHandler.GetPendingCommand) manualRoutes.PATCH(":exec_id/:step_id", manualHandler.PatchContinue) } diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 7e6c1fdd..6cbf520c 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -18,26 +18,19 @@ import ( ) // 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) -// Agent and target for the manual command itself make little sense. -// Unless an agent is the intended system that does post_message, and wait_for_response. -// But the targets? For the automated execution, there is no need to specify any. -// // 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. -// The InteractionCapability manages the manual command infromation and status, like a cache. And interfaces any interactor type (e.g. API, integration) - var log *logger.Log type Empty struct{} @@ -94,7 +87,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { // @failure 400 {object} api.Error // @Router /manual/{exec_id}/{step_id} [GET] func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { - execution_id := g.Param("execution_id") + execution_id := g.Param("exec_id") step_id := g.Param("step_id") execId, err := uuid.Parse(execution_id) if err != nil { @@ -139,7 +132,6 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @failure 400 {object} api.Error // @Router /manual/{exec_id}/{step_id} [PATCH] func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { - paramExecutionId := g.Param("exec_id") paramStepId := g.Param("step_id") @@ -152,7 +144,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { return } - var outArgsUpdate manual.ManualOutArgUpdatePayload + var outArgsUpdate manual.ManualOutArgsUpdatePayload err = json.Unmarshal(jsonData, &outArgsUpdate) if err != nil { log.Error("failed to unmarshal JSON") @@ -163,6 +155,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { } if (outArgsUpdate.ExecutionId != paramExecutionId) || (outArgsUpdate.StepId != paramStepId) { + log.Error("mismatch between execution ID and step ID in url parameters vs request body") apiError.SendErrorResponse(g, http.StatusBadRequest, "Mismatch between execution ID and step ID between URL parameters and request body", "POST /manual/continue/{execution_id}/{step_id}", "") diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 9a24fb8e..e14b2918 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -20,8 +20,7 @@ import ( // - The decomposer passes the PlaybookStepMetadata object to the // action executor, which includes Step // - The action executor calls Execute on the capability (command type) -// passing capability.Context, -// - which includes the Step object +// passing capability.Context, which includes the Step object // - The manual capability calls Queue passing InteractionCommand, // which includes capability.Context // - Queue() posts a message, which shall include the text of the manual command, @@ -54,7 +53,7 @@ type IInteractionStorage interface { GetPendingCommands() ([]manual.InteractionCommandData, int, 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.InteractionCommandData, int, error) - PostContinue(outArgsResult manual.ManualOutArgUpdatePayload) (int, error) + PostContinue(outArgsResult manual.ManualOutArgsUpdatePayload) (int, error) } type InteractionController struct { @@ -166,7 +165,7 @@ func (manualController *InteractionController) GetPendingCommand(metadata execut return interaction.CommandData, http.StatusOK, err } -func (manualController *InteractionController) PostContinue(result manual.ManualOutArgUpdatePayload) (int, error) { +func (manualController *InteractionController) PostContinue(result manual.ManualOutArgsUpdatePayload) (int, error) { log.Trace("completing manual command") metadata, err := manualController.makeExecutionMetadataFromPayload(result) @@ -331,7 +330,7 @@ func (manualController *InteractionController) copyOutArgsToVars(outArgs manual. return vars } -func (manualController *InteractionController) makeExecutionMetadataFromPayload(payload manual.ManualOutArgUpdatePayload) (execution.Metadata, error) { +func (manualController *InteractionController) makeExecutionMetadataFromPayload(payload manual.ManualOutArgsUpdatePayload) (execution.Metadata, error) { executionId, err := uuid.Parse(payload.ExecutionId) if err != nil { return execution.Metadata{}, err diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 90d37817..47d37ef5 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -351,7 +351,7 @@ func TestPostContinueWarningsRaised(t *testing.T) { External: true, // changed but won't be ported } - outArgsUpdate := manualModel.ManualOutArgUpdatePayload{ + outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ Type: "test-manual-response", ExecutionId: testMetadata.ExecutionId.String(), PlaybookId: testMetadata.PlaybookId, @@ -469,7 +469,7 @@ func TestFailOnRetrieveUnexistingExecutionInteraction(t *testing.T) { assert.Equal(t, err, expectedErr) } -func TestFailOnRetrieveUnexistingCommandInteraction(t *testing.T) { +func TestFailOnRetrieveNonExistingCommandInteraction(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) testChan := make(chan manualModel.InteractionResponse) defer close(testChan) diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index ffde2899..999e6899 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -50,7 +50,7 @@ type ManualOutArg struct { type ManualOutArgs map[string]ManualOutArg // The object posted on the manual API Continue() payload -type ManualOutArgUpdatePayload struct { +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 @@ -88,5 +88,5 @@ type InteractionIntegrationCommand struct { // to receive type InteractionIntegrationResponse struct { ResponseError error - Payload ManualOutArgUpdatePayload + Payload ManualOutArgsUpdatePayload } diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index edc98b80..a5c69d83 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -1 +1,130 @@ package manual_api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + api_routes "soarca/pkg/api" + manual_api "soarca/pkg/api/manual" + "soarca/pkg/models/execution" + "soarca/pkg/models/manual" + manual_model "soarca/pkg/models/manual" + "soarca/test/unittest/mocks/mock_interaction_storage" + "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_model.InteractionCommandData{}, 200, 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 := manual.InteractionCommandData{} + + mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(testEmptyResponsePendingCommand, 200, nil) + + request, err := http.NewRequest("GET", path, nil) + if err != nil { + t.Fail() + } + + app.ServeHTTP(recorder, request) + expectedData := testEmptyResponsePendingCommand + expectedJSON, err := json.Marshal(expectedData) + if err != nil { + t.Fatalf("Error marshalling expected JSON: %v", err) + } + expectedString := string(expectedJSON) + assert.Equal(t, expectedString, recorder.Body.String()) + assert.Equal(t, 200, recorder.Code) + + mock_interaction_storage.AssertExpectations(t) +} + +func TestPatchContinueCalled(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/" + testExecId + "/" + testStepId + + testManualResponse := manual_model.ManualOutArgsUpdatePayload{ + Type: "manual-step-response", + ExecutionId: testExecId, + StepId: testStepId, + PlaybookId: testPlaybookId, + ResponseStatus: true, + ResponseOutArgs: manual_model.ManualOutArgs{ + "testvar": { + Type: "string", + Name: "testvar", + Value: "testing!", + }, + }, + } + jsonData, err := json.Marshal(testManualResponse) + if err != nil { + t.Fatalf("Error marshalling JSON: %v", err) + } + + mock_interaction_storage.On("PostContinue", testManualResponse).Return(200, nil) + + request, err := http.NewRequest("PATCH", 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) +} 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..ca3df7c7 --- /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.InteractionCommandData, int, error) { + args := mock.Called() + return args.Get(0).([]manual.InteractionCommandData), args.Int(1), args.Error(2) +} + +func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, int, error) { + args := mock.Called(metadata) + return args.Get(0).(manual.InteractionCommandData), args.Int(1), args.Error(2) +} + +func (mock *MockInteractionStorage) PostContinue(outArgsResult manual.ManualOutArgsUpdatePayload) (int, error) { + args := mock.Called(outArgsResult) + return args.Int(0), args.Error(1) +} From 906282d0a2985d0b200f288d627461ccb54e7554 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:22:04 +0100 Subject: [PATCH 38/63] update documentation and terminology to latest code status --- .../en/docs/core-components/api-manual.md | 18 +++++++++--------- .../content/en/docs/core-components/modules.md | 3 ++- pkg/api/manual/manual_api.go | 8 ++++---- pkg/models/manual/manual.go | 2 +- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 7dcba4f2..1483f674 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -34,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: @@ -46,7 +46,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 +|commandBase64 |command |string |The command in base 64 if present |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 @@ -98,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: @@ -164,8 +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 name and value |dictionary |Map of cacao variables's names and values, as per variables handled in the step out args +|response_status |true / false |string |`true` indicates successfull fulfilment of the manual request. `false` indicates failed satisfaction of request +|response_out_args |cacao variables |dictionary |Map of cacao variables names to cacao variable struct. Only name, type, and value are mandatory ```plantuml @@ -179,12 +179,12 @@ Respond to manual command pending in SOARCA, if out_args are defined they must b "response_status" : "success | failed", "response_out_args": { "" : { - "type": "", + "type": "", "name": "", - "description": "", "value": "", - "constant": "", - "external": "" + "description": " (ignored)", + "constant": " (ignored)", + "external": " (ignored)" } } } diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 8b299de8..7d0ad887 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -346,7 +346,8 @@ class ManualStep protocol ManualAPI { GET /manual - POST /manual/continue + GET /manual/{exec-id}/{step-id} + PATCH /manual/{exec-id}/{step-id} } interface ICapability{ diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 6cbf520c..9e659baf 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -140,7 +140,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error("failed") apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to read json", - "POST /manual/continue/{exec_id}/{step_id}", "") + "PATCH /manual/{exec_id}/{step_id}", "") return } @@ -150,7 +150,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error("failed to unmarshal JSON") apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to unmarshal JSON", - "POST /manual/continue/{exec_id}/{step_id}", "") + "PATCH /manual/{exec_id}/{step_id}", "") return } @@ -158,7 +158,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error("mismatch between execution ID and step ID in url parameters vs request body") apiError.SendErrorResponse(g, http.StatusBadRequest, "Mismatch between execution ID and step ID between URL parameters and request body", - "POST /manual/continue/{execution_id}/{step_id}", "") + "PATCH /manual/{execution_id}/{step_id}", "") return } @@ -167,7 +167,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, "Failed to post continue ID", - "POST /manual/continue", err.Error()) + "PATCH /manual/{execution_id}/{step_id}", err.Error()) return } g.JSON( diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 999e6899..73b4d5db 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -55,7 +55,7 @@ type ManualOutArgsUpdatePayload struct { 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 bool `bson:"response_status" json:"response_status" validate:"required"` // Can be either success or failure + ResponseStatus bool `bson:"response_status" json:"response_status" validate:"required"` // true indicates success, all good. false indicates request not met. ResponseOutArgs ManualOutArgs `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions } From a2569c669929f245027d79f5cc576e65f5ade561 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:18:36 +0100 Subject: [PATCH 39/63] update tests --- .../manual/interaction/interaction.go | 10 +- .../manual/interaction/interaction_test.go | 147 +++++++++++++++++- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index e14b2918..3e86b774 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -182,8 +182,16 @@ func (manualController *InteractionController) PostContinue(result manual.Manual // If it is for varName, variable := range result.ResponseOutArgs { + // Sanity check that dictionary key matches variable name + if varName != variable.Name { + err := fmt.Errorf("provided out arg key [ %s ] does not match its name property [ %s ]", varName, variable.Name) + log.Error(err) + return http.StatusBadRequest, err + } + // first check that out args provided match the variables if _, ok := pendingEntry.CommandData.OutVariables[varName]; !ok { + err := errors.New("provided out args do not match command-related variables") log.Warning("provided out args do not match command-related variables") return http.StatusBadRequest, err } @@ -207,7 +215,7 @@ func (manualController *InteractionController) PostContinue(result manual.Manual //Then put outArgs back into manualCapabilityChannel // Copy result and conversion back to interactionResponse format returnedVars := manualController.copyOutArgsToVars(result.ResponseOutArgs) - log.Info("putting stuff in manual capability channel") + log.Trace("pushing assigned variables in manual capability channel") pendingEntry.Channel <- manual.InteractionResponse{ ResponseError: nil, Payload: returnedVars, diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 47d37ef5..2e0a382e 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -335,13 +335,6 @@ func TestPostContinueWarningsRaised(t *testing.T) { t.Fail() } - pending, err := interaction.getPendingInteraction((testMetadata)) - if err != nil { - t.Log(err) - t.Fail() - } - fmt.Println(pending) - outArg := manualModel.ManualOutArg{ Type: "string", Name: "var2", @@ -397,8 +390,146 @@ func TestPostContinueWarningsRaised(t *testing.T) { } -func TestPostContinueFailOnUnmatchedOutArgs(t *testing.T) { +func TestPostContinueFailOnUnmatchedOutArgsKeyName(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 := manualModel.ManualOutArg{ + Type: "string", + Name: "testNotExisting", + Value: "now the value is bananas", + } + + outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ + Type: "test-manual-response", + ExecutionId: testMetadata.ExecutionId.String(), + PlaybookId: testMetadata.PlaybookId, + StepId: testMetadata.StepId, + ResponseStatus: true, + ResponseOutArgs: manualModel.ManualOutArgs{"asd": outArg}, + } + + statusCode, err := interaction.PostContinue(outArgsUpdate) + + expectedStatusCode := 400 + expectedErr := errors.New("provided out arg key [ asd ] does not match its name property [ testNotExisting ]") + + expectedLogEntry1 := "provided out arg key [ asd ] does not match its name property [ testNotExisting ]" + expectedLogs := []string{expectedLogEntry1} + + all := true + for _, expectedMessage := range expectedLogs { + containsAll := true + for _, entry := range hook.Entries { + if strings.Contains(expectedMessage, entry.Message) { + containsAll = true + break + } + if !strings.Contains(expectedMessage, entry.Message) { + containsAll = false + } + } + if !containsAll { + t.Logf("log message: '%s' not found in logged messages", expectedMessage) + all = false + break + } + } + + assert.Equal(t, statusCode, expectedStatusCode) + assert.Equal(t, err, expectedErr) + + 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 := manualModel.ManualOutArg{ + Type: "string", + Name: "testNotExisting", + Value: "now the value is bananas", + } + + outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ + Type: "test-manual-response", + ExecutionId: testMetadata.ExecutionId.String(), + PlaybookId: testMetadata.PlaybookId, + StepId: testMetadata.StepId, + ResponseStatus: true, + ResponseOutArgs: manualModel.ManualOutArgs{"testNotExisting": outArg}, + } + + statusCode, err := interaction.PostContinue(outArgsUpdate) + + expectedStatusCode := 400 + expectedErr := errors.New("provided out args do not match command-related variables") + + expectedLogEntry1 := "provided out args do not match command-related variables" + expectedLogs := []string{expectedLogEntry1} + + all := true + for _, expectedMessage := range expectedLogs { + containsAll := true + for _, entry := range hook.Entries { + if strings.Contains(expectedMessage, entry.Message) { + containsAll = true + break + } + if !strings.Contains(expectedMessage, entry.Message) { + containsAll = false + } + } + if !containsAll { + t.Logf("log message: '%s' not found in logged messages", expectedMessage) + all = false + break + } + } + + assert.Equal(t, statusCode, expectedStatusCode) + assert.Equal(t, err, expectedErr) + + assert.NotEqual(t, len(hook.Entries), 0) + assert.Equal(t, all, true) } func TestRegisterRetrieveNewExecutionNewPendingInteraction(t *testing.T) { From 100a3c5778093a5f12590ee4473deb931c586282 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:50:14 +0100 Subject: [PATCH 40/63] update tests --- pkg/core/capability/manual/interaction/interaction_test.go | 7 ++++--- pkg/models/manual/manual.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 2e0a382e..a3ff1eec 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -171,14 +171,14 @@ func TestGetAllPendingInteractions(t *testing.T) { expectedInteractionsJson := ` [ { - "type": "test_type", + "type": "test_type", "execution_id": "61a6c41e-6efc-4516-a242-dfbc5c89d562", "playbook_id": "test_playbook_id", "step_id": "test_step_id", "description": "test_description", "command": "test_command", "commandb64": "test_command_b64", - "targets": { + "target": { "id": "test_id", "type": "test_type", "name": "test_name", @@ -203,7 +203,7 @@ func TestGetAllPendingInteractions(t *testing.T) { "description": "test_description", "command": "test_command", "commandb64": "test_command_b64", - "targets": { + "target": { "id": "test_id", "type": "test_type", "name": "test_name", @@ -232,6 +232,7 @@ func TestGetAllPendingInteractions(t *testing.T) { t.Log(expectedInteractions) receivedInteractions := interaction.getAllPendingInteractions() + fmt.Println(receivedInteractions) if !reflect.DeepEqual(expectedInteractions, receivedInteractions) { err = fmt.Errorf("expected %v, but got %v", expectedInteractions, receivedInteractions) diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 73b4d5db..3b680023 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -20,7 +20,7 @@ type InteractionCommandData struct { 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 CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present - Target cacao.AgentTarget `bson:"targets" json:"targets" validate:"required"` // Map of cacao agent-target with the target(s) of this command + 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 } From a87fe545bcdefde1241c854ad8b089da453ddff1 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 10 Jan 2025 16:02:34 +0100 Subject: [PATCH 41/63] revert to postcontinue instead of patch on ids --- .../en/docs/core-components/api-manual.md | 4 +- .../en/docs/core-components/modules.md | 8 +-- pkg/api/api.go | 2 +- pkg/api/manual/manual_api.go | 20 ++---- .../manual/interaction/interaction.go | 7 -- .../manual/interaction/interaction_test.go | 71 ------------------- .../api/routes/manual_api/manual_api_test.go | 6 +- 7 files changed, 15 insertions(+), 103 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index 1483f674..f8602148 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -18,7 +18,7 @@ We will use HTTP status codes https://en.wikipedia.org/wiki/List_of_HTTP_status_ protocol Manual { GET /manual GET /manual/{execution-id}/{step-id} - PATCH /manual/{execution-id}/{step-id} + POST /manual/continue } @enduml ``` @@ -154,7 +154,7 @@ None 404/Not found with payload: General error -#### PATCH `/manual//` +#### 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. If the object does not match the original out_arg, the call we be considered as failed. ##### Call payload diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 7d0ad887..3e5d6a90 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -346,8 +346,8 @@ class ManualStep protocol ManualAPI { GET /manual - GET /manual/{exec-id}/{step-id} - PATCH /manual/{exec-id}/{step-id} + GET /manual/{exec-id}/{step-id} + POST /manual/continue } interface ICapability{ @@ -361,7 +361,7 @@ interface ICapabilityInteraction{ interface IInteracionStorage{ GetPendingCommands() []CommandData GetPendingCommand(execution.metadata) CommandData - Continue(execution.metadata) StatusCode + PostContinue(execution.metadata) StatusCode } interface IInteractionIntegrationNotifier { @@ -420,7 +420,7 @@ else Native ManualAPI flow interaction ->> interaction : async wait on integrationChannel api -> interaction : GetPendingCommands() api -> interaction : GetPendingCommand(execution.metadata) -api -> interaction : Continue(InteractionResponse) +api -> interaction : PostContinue(InteractionResponse) interaction --> manual : capabilityChannel <- InteractionResponse end diff --git a/pkg/api/api.go b/pkg/api/api.go index 7481861e..f1dc9698 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -141,6 +141,6 @@ func ManualRoutes(route *gin.Engine, manualHandler *manual_handler.ManualHandler { manualRoutes.GET("/", manualHandler.GetPendingCommands) manualRoutes.GET(":exec_id/:step_id", manualHandler.GetPendingCommand) - manualRoutes.PATCH(":exec_id/:step_id", manualHandler.PatchContinue) + manualRoutes.POST("/continue", manualHandler.PostContinue) } } diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 9e659baf..eb705e1d 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -130,17 +130,15 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @Param response_out_args body manual.ManualOutArgs true "out args" // @Success 200 {object} api.Execution // @failure 400 {object} api.Error -// @Router /manual/{exec_id}/{step_id} [PATCH] -func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { - paramExecutionId := g.Param("exec_id") - paramStepId := g.Param("step_id") +// @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", - "PATCH /manual/{exec_id}/{step_id}", "") + "POST /manual/continue", "") return } @@ -150,15 +148,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error("failed to unmarshal JSON") apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to unmarshal JSON", - "PATCH /manual/{exec_id}/{step_id}", "") - return - } - - if (outArgsUpdate.ExecutionId != paramExecutionId) || (outArgsUpdate.StepId != paramStepId) { - log.Error("mismatch between execution ID and step ID in url parameters vs request body") - apiError.SendErrorResponse(g, http.StatusBadRequest, - "Mismatch between execution ID and step ID between URL parameters and request body", - "PATCH /manual/{execution_id}/{step_id}", "") + "POST /manual/continue", "") return } @@ -167,7 +157,7 @@ func (manualHandler *ManualHandler) PatchContinue(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, "Failed to post continue ID", - "PATCH /manual/{execution_id}/{step_id}", err.Error()) + "POST /manual/continue", err.Error()) return } g.JSON( diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 3e86b774..0fae4541 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -182,13 +182,6 @@ func (manualController *InteractionController) PostContinue(result manual.Manual // If it is for varName, variable := range result.ResponseOutArgs { - // Sanity check that dictionary key matches variable name - if varName != variable.Name { - err := fmt.Errorf("provided out arg key [ %s ] does not match its name property [ %s ]", varName, variable.Name) - log.Error(err) - return http.StatusBadRequest, err - } - // first check that out args provided match the variables if _, ok := pendingEntry.CommandData.OutVariables[varName]; !ok { err := errors.New("provided out args do not match command-related variables") diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index a3ff1eec..ae219900 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -391,77 +391,6 @@ func TestPostContinueWarningsRaised(t *testing.T) { } -func TestPostContinueFailOnUnmatchedOutArgsKeyName(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 := manualModel.ManualOutArg{ - Type: "string", - Name: "testNotExisting", - Value: "now the value is bananas", - } - - outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ - Type: "test-manual-response", - ExecutionId: testMetadata.ExecutionId.String(), - PlaybookId: testMetadata.PlaybookId, - StepId: testMetadata.StepId, - ResponseStatus: true, - ResponseOutArgs: manualModel.ManualOutArgs{"asd": outArg}, - } - - statusCode, err := interaction.PostContinue(outArgsUpdate) - - expectedStatusCode := 400 - expectedErr := errors.New("provided out arg key [ asd ] does not match its name property [ testNotExisting ]") - - expectedLogEntry1 := "provided out arg key [ asd ] does not match its name property [ testNotExisting ]" - expectedLogs := []string{expectedLogEntry1} - - all := true - for _, expectedMessage := range expectedLogs { - containsAll := true - for _, entry := range hook.Entries { - if strings.Contains(expectedMessage, entry.Message) { - containsAll = true - break - } - if !strings.Contains(expectedMessage, entry.Message) { - containsAll = false - } - } - if !containsAll { - t.Logf("log message: '%s' not found in logged messages", expectedMessage) - all = false - break - } - } - - assert.Equal(t, statusCode, expectedStatusCode) - assert.Equal(t, err, expectedErr) - - assert.NotEqual(t, len(hook.Entries), 0) - assert.Equal(t, all, true) -} - func TestPostContinueFailOnNonexistingVariable(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) timeout := 500 * time.Millisecond diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index a5c69d83..97d5c123 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -82,7 +82,7 @@ func TestGetPendingCommandCalled(t *testing.T) { mock_interaction_storage.AssertExpectations(t) } -func TestPatchContinueCalled(t *testing.T) { +func TestPostContinueCalled(t *testing.T) { mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) @@ -94,7 +94,7 @@ func TestPatchContinueCalled(t *testing.T) { testExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" testStepId := "61a4d52c-6efc-4516-a242-dfbc5c89d312" testPlaybookId := "21a4d52c-6efc-4516-a242-dfbc5c89d312" - path := "/manual/" + testExecId + "/" + testStepId + path := "/manual/continue" testManualResponse := manual_model.ManualOutArgsUpdatePayload{ Type: "manual-step-response", @@ -117,7 +117,7 @@ func TestPatchContinueCalled(t *testing.T) { mock_interaction_storage.On("PostContinue", testManualResponse).Return(200, nil) - request, err := http.NewRequest("PATCH", path, bytes.NewBuffer(jsonData)) + request, err := http.NewRequest("POST", path, bytes.NewBuffer(jsonData)) if err != nil { t.Fail() } From f7cb2cfe780c49588c1dcce38f1c47641e79435f Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:23:52 +0100 Subject: [PATCH 42/63] update documentation with more explicit info --- .../en/docs/core-components/modules.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 3e5d6a90..63e66b10 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -322,12 +322,12 @@ 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 capability will allow an operator to interact with a playbook. It could allow one to perform a manual step that could not be automated, enter a variable to the playbook execution or a combination of these operations. -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. +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 @@ -338,11 +338,18 @@ In essence, executing a manual command involves the following actions: Because the *somewhere* and *somehow* for posting a message can vary, and the *something* that replies can vary too, 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 manual command will queue the instruction into the *CapabilityInteraction* module. The module does essentially three things: +1. it stores the status of the manual command, and handles 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 *ManualCommand* 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 ManualStep +class ManualCommand protocol ManualAPI { GET /manual @@ -375,8 +382,8 @@ class Interaction { class ThirdPartyManualIntegration -ManualStep .up.|> ICapability -ManualStep -down-> ICapabilityInteraction +ManualCommand .up.|> ICapability +ManualCommand -down-> ICapabilityInteraction Interaction .up.|> ICapabilityInteraction Interaction .up.|> IInteracionStorage @@ -396,7 +403,7 @@ The diagram below displays in some detail the way the manual interactions compon ```plantuml @startuml -control "ManualStep" as manual +control "ManualCommand" as manual control "Interaction" as interaction control "ManualAPI" as api control "ThirdPartyManualIntegration" as 3ptool From b8f9e3cd010e66e551aab9aca3b000e329a29a45 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Thu, 16 Jan 2025 17:55:04 +0100 Subject: [PATCH 43/63] change manual models but tests are now broken --- pkg/api/manual/manual_api.go | 26 +++- .../manual/interaction/interaction.go | 147 ++++++------------ .../manual/interaction/interaction_test.go | 77 ++++----- pkg/core/capability/manual/manual.go | 8 +- pkg/core/capability/manual/manual_test.go | 4 +- pkg/models/api/manual.go | 27 ++++ pkg/models/cacao/cacao.go | 2 +- pkg/models/manual/manual.go | 80 ++-------- .../api/routes/manual_api/manual_api_test.go | 21 +-- .../mock_interaction/mock_interaction.go | 2 +- .../mock_interaction_storage.go | 12 +- 11 files changed, 180 insertions(+), 226 deletions(-) create mode 100644 pkg/models/api/manual.go diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index eb705e1d..45139de5 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -8,6 +8,7 @@ import ( "soarca/internal/logger" "soarca/pkg/core/capability/manual/interaction" "soarca/pkg/models/api" + apiModel "soarca/pkg/models/api" "soarca/pkg/models/execution" "soarca/pkg/models/manual" @@ -142,7 +143,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } - var outArgsUpdate manual.ManualOutArgsUpdatePayload + var outArgsUpdate apiModel.ManualOutArgsUpdatePayload err = json.Unmarshal(jsonData, &outArgsUpdate) if err != nil { log.Error("failed to unmarshal JSON") @@ -152,7 +153,28 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } - status, err := manualHandler.interactionCapability.PostContinue(outArgsUpdate) + // Create object to pass to interaction capability + executionId, err := uuid.Parse(outArgsUpdate.ExecutionId) + if err != nil { + log.Error(err) + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Failed to parse execution ID", + "POST /manual/continue", err.Error()) + return + } + + interactionResponse := manual.InteractionResponse{ + Metadata: execution.Metadata{ + StepId: outArgsUpdate.StepId, + ExecutionId: executionId, + PlaybookId: outArgsUpdate.PlaybookId, + }, + OutArgsVariables: outArgsUpdate.ResponseOutArgs, + ResponseStatus: outArgsUpdate.ResponseStatus, + ResponseError: nil, + } + + status, err := manualHandler.interactionCapability.PostContinue(interactionResponse) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 0fae4541..562fffa1 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -6,11 +6,8 @@ import ( "net/http" "reflect" "soarca/internal/logger" - "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "soarca/pkg/models/manual" - - "github.com/google/uuid" ) // TODO @@ -42,18 +39,18 @@ func init() { } type IInteractionIntegrationNotifier interface { - Notify(command manual.InteractionIntegrationCommand, channel chan manual.InteractionIntegrationResponse) + Notify(command manual.InteractionIntegrationCommand, channel chan manual.InteractionResponse) } type ICapabilityInteraction interface { - Queue(command manual.InteractionCommand, manualComms manual.ManualCapabilityCommunication) error + Queue(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) error } type IInteractionStorage interface { - GetPendingCommands() ([]manual.InteractionCommandData, int, error) + GetPendingCommands() ([]manual.CommandInfo, int, 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.InteractionCommandData, int, error) - PostContinue(outArgsResult manual.ManualOutArgsUpdatePayload) (int, error) + GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, int, error) + PostContinue(response manual.InteractionResponse) (int, error) } type InteractionController struct { @@ -69,10 +66,18 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr } } +// TODO: +// - Change registration of command data to only keep InteractionCommand object, move InteractionCommandData to api model +// - Add interactionintegration channel in interaction storage entry +// - Add check on timeoutcontext.Done() for timeout (vs completion), and remove entry from pending in that case +// - Change waitInteractionIntegrationResponse to be waitResponse +// - Put result := <- interactionintegrationchannel into a separate function +// - Just use the one instance of manual capability channel. Do not use interactionintegrationchannel + // ############################################################################ // ICapabilityInteraction implementation // ############################################################################ -func (manualController *InteractionController) Queue(command manual.InteractionCommand, manualComms manual.ManualCapabilityCommunication) error { +func (manualController *InteractionController) Queue(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) error { err := manualController.registerPendingInteraction(command, manualComms.Channel) if err != nil { @@ -87,7 +92,7 @@ func (manualController *InteractionController) Queue(command manual.InteractionC integrationCommand := manual.InteractionIntegrationCommand(command) // One response channel for all integrations - integrationChannel := make(chan manual.InteractionIntegrationResponse) + integrationChannel := make(chan manual.InteractionResponse) for _, notifier := range manualController.Notifiers { go notifier.Notify(integrationCommand, integrationChannel) @@ -99,7 +104,7 @@ func (manualController *InteractionController) Queue(command manual.InteractionC return nil } -func (manualController *InteractionController) waitInteractionIntegrationResponse(manualComms manual.ManualCapabilityCommunication, integrationChannel chan manual.InteractionIntegrationResponse) { +func (manualController *InteractionController) waitInteractionIntegrationResponse(manualComms manual.ManualCapabilityCommunication, integrationChannel chan manual.InteractionResponse) { defer close(integrationChannel) for { select { @@ -113,38 +118,17 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons case result := <-integrationChannel: // Check register for pending manual command - metadata, err := manualController.makeExecutionMetadataFromPayload(result.Payload) - if err != nil { - log.Error(err) - manualComms.Channel <- manual.InteractionResponse{ - ResponseError: err, - Payload: cacao.Variables{}, - } - return - } // Remove interaction from pending ones - err = manualController.removeInteractionFromPending(metadata) + err := manualController.removeInteractionFromPending(result.Metadata) if err != nil { // If it was not there, was already resolved log.Warning(err) // Captured if channel not yet closed log.Warning("manual command not found among pending ones. should be already resolved") - manualComms.Channel <- manual.InteractionResponse{ - ResponseError: err, - Payload: cacao.Variables{}, - } return } - // Copy result and conversion back to interactionResponse format - returnedVars := manualController.copyOutArgsToVars(result.Payload.ResponseOutArgs) - - interactionResponse := manual.InteractionResponse{ - ResponseError: result.ResponseError, - Payload: returnedVars, - } - - manualComms.Channel <- interactionResponse + manualComms.Channel <- result return } } @@ -153,43 +137,38 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons // ############################################################################ // IInteractionStorage implementation // ############################################################################ -func (manualController *InteractionController) GetPendingCommands() ([]manual.InteractionCommandData, int, error) { +func (manualController *InteractionController) GetPendingCommands() ([]manual.CommandInfo, int, error) { log.Trace("getting pending manual commands") return manualController.getAllPendingInteractions(), http.StatusOK, nil } -func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, int, error) { +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, int, error) { log.Trace("getting pending manual command") interaction, err := manualController.getPendingInteraction(metadata) // TODO: determine status code - return interaction.CommandData, http.StatusOK, err + return interaction.CommandInfo, http.StatusOK, err } -func (manualController *InteractionController) PostContinue(result manual.ManualOutArgsUpdatePayload) (int, error) { +func (manualController *InteractionController) PostContinue(response manual.InteractionResponse) (int, error) { log.Trace("completing manual command") - metadata, err := manualController.makeExecutionMetadataFromPayload(result) - if err != nil { - return http.StatusBadRequest, err - } - // If not in there, it means it was already solved (right?) - pendingEntry, err := manualController.getPendingInteraction(metadata) + pendingEntry, err := manualController.getPendingInteraction(response.Metadata) if err != nil { log.Warning(err) return http.StatusAlreadyReported, err } // If it is - for varName, variable := range result.ResponseOutArgs { + for varName, variable := range response.OutArgsVariables { // first check that out args provided match the variables - if _, ok := pendingEntry.CommandData.OutVariables[varName]; !ok { + if _, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; !ok { err := errors.New("provided out args do not match command-related variables") log.Warning("provided out args do not match command-related variables") return http.StatusBadRequest, err } // then warn if any value outside "value" has changed - if pending, ok := pendingEntry.CommandData.OutVariables[varName]; ok { + if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { if variable.Constant != pending.Constant { log.Warningf("provided out arg %s is attempting to change 'Constant' property", varName) } @@ -207,14 +186,14 @@ func (manualController *InteractionController) PostContinue(result manual.Manual //Then put outArgs back into manualCapabilityChannel // Copy result and conversion back to interactionResponse format - returnedVars := manualController.copyOutArgsToVars(result.ResponseOutArgs) + returnedVars := response.OutArgsVariables log.Trace("pushing assigned variables in manual capability channel") pendingEntry.Channel <- manual.InteractionResponse{ - ResponseError: nil, - Payload: returnedVars, + ResponseError: nil, + OutArgsVariables: returnedVars, } // de-register the command - err = manualController.removeInteractionFromPending(metadata) + err = manualController.removeInteractionFromPending(response.Metadata) if err != nil { log.Error(err) return http.StatusInternalServerError, err @@ -226,27 +205,21 @@ func (manualController *InteractionController) PostContinue(result manual.Manual // ############################################################################ // Utilities and functionalities // ############################################################################ -func (manualController *InteractionController) registerPendingInteraction(command manual.InteractionCommand, manualChan chan manual.InteractionResponse) error { - - interaction := manual.InteractionCommandData{ - Type: command.Context.Command.Type, - ExecutionId: command.Metadata.ExecutionId.String(), - PlaybookId: command.Metadata.PlaybookId, - StepId: command.Metadata.StepId, - Description: command.Context.Command.Description, - Command: command.Context.Command.Command, - CommandBase64: command.Context.Command.CommandB64, - Target: command.Context.Target, - OutVariables: command.Context.Variables.Select(command.Context.Step.OutArgs), +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[interaction.ExecutionId] + 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[interaction.ExecutionId] = map[string]manual.InteractionStorageEntry{ - interaction.StepId: { - CommandData: interaction, + manualController.InteractionStorage[commandInfo.Metadata.ExecutionId.String()] = map[string]manual.InteractionStorageEntry{ + commandInfo.Metadata.StepId: { + CommandInfo: commandInfo, Channel: manualChan, }, } @@ -254,30 +227,30 @@ func (manualController *InteractionController) registerPendingInteraction(comman } // There is an execution entry - if _, ok := execution[interaction.StepId]; ok { + 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.", - interaction.ExecutionId, interaction.StepId) + 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[interaction.StepId] = manual.InteractionStorageEntry{ - CommandData: interaction, + execution[commandInfo.Metadata.StepId] = manual.InteractionStorageEntry{ + CommandInfo: commandInfo, Channel: manualChan, } return nil } -func (manualController *InteractionController) getAllPendingInteractions() []manual.InteractionCommandData { - allPendingInteractions := []manual.InteractionCommandData{} +func (manualController *InteractionController) getAllPendingInteractions() []manual.CommandInfo { + allPendingInteractions := []manual.CommandInfo{} for _, interactions := range manualController.InteractionStorage { for _, interaction := range interactions { - allPendingInteractions = append(allPendingInteractions, interaction.CommandData) + allPendingInteractions = append(allPendingInteractions, interaction.CommandInfo) } } return allPendingInteractions @@ -317,29 +290,3 @@ func (manualController *InteractionController) removeInteractionFromPending(comm } return nil } - -func (manualController *InteractionController) copyOutArgsToVars(outArgs manual.ManualOutArgs) cacao.Variables { - vars := cacao.NewVariables() - for name, outVar := range outArgs { - - vars[name] = cacao.Variable{ - Type: outVar.Type, - Name: outVar.Name, - Value: outVar.Value, - } - } - return vars -} - -func (manualController *InteractionController) makeExecutionMetadataFromPayload(payload manual.ManualOutArgsUpdatePayload) (execution.Metadata, error) { - executionId, err := uuid.Parse(payload.ExecutionId) - if err != nil { - return execution.Metadata{}, err - } - metadata := execution.Metadata{ - ExecutionId: executionId, - PlaybookId: payload.PlaybookId, - StepId: payload.StepId, - } - return metadata, nil -} diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index ae219900..60a064b4 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -40,7 +40,7 @@ func TestQueue(t *testing.T) { func TestQueueFailWithoutTimeout(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) - testCommand := manualModel.InteractionCommand{} + testCommand := manualModel.CommandInfo{} testCapComms := manualModel.ManualCapabilityCommunication{ Channel: make(chan manualModel.InteractionResponse), @@ -100,50 +100,45 @@ func TestRegisterRetrieveNewPendingInteraction(t *testing.T) { testChan, ) - // Type - assert.Equal(t, - retrievedCommand.CommandData.Type, - testInteractionCommand.Context.Command.Type, - ) // ExecutionId assert.Equal(t, - retrievedCommand.CommandData.ExecutionId, + retrievedCommand.CommandInfo.Metadata.ExecutionId.String(), testInteractionCommand.Metadata.ExecutionId.String(), ) // PlaybookId assert.Equal(t, - retrievedCommand.CommandData.PlaybookId, + retrievedCommand.CommandInfo.Metadata.PlaybookId, testInteractionCommand.Metadata.PlaybookId, ) // StepId assert.Equal(t, - retrievedCommand.CommandData.StepId, + retrievedCommand.CommandInfo.Metadata.StepId, testInteractionCommand.Metadata.StepId, ) // Description assert.Equal(t, - retrievedCommand.CommandData.Description, + retrievedCommand.CommandInfo.Context.Command.Description, testInteractionCommand.Context.Command.Description, ) // Command assert.Equal(t, - retrievedCommand.CommandData.Command, + retrievedCommand.CommandInfo.Context.Command.Command, testInteractionCommand.Context.Command.Command, ) // CommandB64 assert.Equal(t, - retrievedCommand.CommandData.CommandBase64, + retrievedCommand.CommandInfo.Context.Command.CommandB64, testInteractionCommand.Context.Command.CommandB64, ) // Target assert.Equal(t, - retrievedCommand.CommandData.Target, + retrievedCommand.CommandInfo.Context.Target, testInteractionCommand.Context.Target, ) // OutArgs assert.Equal(t, - retrievedCommand.CommandData.OutVariables, - testInteractionCommand.Context.Variables, + retrievedCommand.CommandInfo.OutArgsVariables, + testInteractionCommand.OutArgsVariables, ) } @@ -222,7 +217,7 @@ func TestGetAllPendingInteractions(t *testing.T) { } ] ` - var expectedInteractions []manualModel.InteractionCommandData + var expectedInteractions []manualModel.InteractionResponse err = json.Unmarshal([]byte(expectedInteractionsJson), &expectedInteractions) if err != nil { t.Log(err) @@ -290,7 +285,7 @@ func TestCopyOutArgsToVars(t *testing.T) { t.Fail() } - outArg := manualModel.ManualOutArg{ + outArg := cacao.Variable{ Type: "string", Name: "var2", Description: "this description will not make it to the returned var", @@ -305,9 +300,9 @@ func TestCopyOutArgsToVars(t *testing.T) { Value: "now the value is bananas", } - responseOutArgs := manualModel.ManualOutArgs{"var2": outArg} + responseOutArgs := cacao.Variables{"var2": outArg} - vars := interaction.copyOutArgsToVars(responseOutArgs) + 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) @@ -336,7 +331,7 @@ func TestPostContinueWarningsRaised(t *testing.T) { t.Fail() } - outArg := manualModel.ManualOutArg{ + outArg := cacao.Variable{ Type: "string", Name: "var2", Description: "this description will not make it to the returned var", @@ -345,13 +340,11 @@ func TestPostContinueWarningsRaised(t *testing.T) { External: true, // changed but won't be ported } - outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ - Type: "test-manual-response", - ExecutionId: testMetadata.ExecutionId.String(), - PlaybookId: testMetadata.PlaybookId, - StepId: testMetadata.StepId, - ResponseStatus: true, - ResponseOutArgs: manualModel.ManualOutArgs{"var2": outArg}, + outArgsUpdate := manualModel.InteractionResponse{ + Metadata: testMetadata, + ResponseStatus: "success", + ResponseError: nil, + OutArgsVariables: cacao.Variables{"var2": outArg}, } statusCode, err := interaction.PostContinue(outArgsUpdate) @@ -413,19 +406,17 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { t.Fail() } - outArg := manualModel.ManualOutArg{ + outArg := cacao.Variable{ Type: "string", Name: "testNotExisting", Value: "now the value is bananas", } - outArgsUpdate := manualModel.ManualOutArgsUpdatePayload{ - Type: "test-manual-response", - ExecutionId: testMetadata.ExecutionId.String(), - PlaybookId: testMetadata.PlaybookId, - StepId: testMetadata.StepId, - ResponseStatus: true, - ResponseOutArgs: manualModel.ManualOutArgs{"testNotExisting": outArg}, + outArgsUpdate := manualModel.InteractionResponse{ + Metadata: testMetadata, + ResponseStatus: "success", + ResponseError: nil, + OutArgsVariables: cacao.Variables{"testNotExisting": outArg}, } statusCode, err := interaction.PostContinue(outArgsUpdate) @@ -576,11 +567,11 @@ func TestRemovePendingInteraciton(t *testing.T) { t.Fail() } assert.Equal(t, - pendingCommand.CommandData.ExecutionId, + pendingCommand.CommandInfo.Metadata.ExecutionId.String(), testInteractionCommand.Metadata.ExecutionId.String(), ) assert.Equal(t, - pendingCommand.CommandData.StepId, + pendingCommand.CommandInfo.Metadata.StepId, testInteractionCommand.Metadata.StepId, ) @@ -631,8 +622,18 @@ var testMetadata = execution.Metadata{ StepId: "test_step_id", } -var testInteractionCommand = manualModel.InteractionCommand{ +var testInteractionCommand = manualModel.CommandInfo{ Metadata: testMetadata, + OutArgsVariables: cacao.Variables{ + "var1": { + Type: "string", + Name: "var1", + Description: "test variable", + Value: "", + Constant: false, + External: false, + }, + }, Context: capability.Context{ Command: cacao.Command{ Type: "test_type", diff --git a/pkg/core/capability/manual/manual.go b/pkg/core/capability/manual/manual.go index 24938dd4..c377ee50 100644 --- a/pkg/core/capability/manual/manual.go +++ b/pkg/core/capability/manual/manual.go @@ -44,7 +44,11 @@ func (manual *ManualCapability) Execute( metadata execution.Metadata, commandContext capability.Context) (cacao.Variables, error) { - command := manualModel.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) @@ -82,7 +86,7 @@ func (manual *ManualCapability) awaitUserInput(channel chan manualModel.Interact return cacao.NewVariables(), err case response := <-channel: log.Trace("received response from api") - cacaoVars := response.Payload + 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 4fc96c0e..2f83f594 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -23,7 +23,7 @@ func TestManualExecution(t *testing.T) { meta := execution.Metadata{} commandContext := capability.Context{} - command := manualModel.InteractionCommand{} + command := manualModel.CommandInfo{} // Capture the channel passed to Queue @@ -45,7 +45,7 @@ func TestManualExecution(t *testing.T) { // Simulate the response after ensuring the channel is captured time.Sleep(100 * time.Millisecond) capturedComm.Channel <- manualModel.InteractionResponse{ - Payload: cacao.NewVariables(), + OutArgsVariables: cacao.NewVariables(), } // Wait for the Execute method to complete diff --git a/pkg/models/api/manual.go b/pkg/models/api/manual.go new file mode 100644 index 00000000..4b9c67f9 --- /dev/null +++ b/pkg/models/api/manual.go @@ -0,0 +1,27 @@ +package api + +import "soarca/pkg/models/cacao" + +// 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 + CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present + 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 string `bson:"response_status" json:"response_status" validate:"required"` // true indicates success, all good. false indicates request not met. + 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 a3c2b382..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 diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 3b680023..f1de2def 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -11,82 +11,34 @@ import ( // Data structures for native SOARCA manual command handling // ################################################################################ -// Object stored in interaction storage and provided back from the API -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 - CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present - 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 -} - type InteractionStorageEntry struct { - CommandData InteractionCommandData + CommandInfo CommandInfo Channel chan InteractionResponse } // Object passed by the manual capability to the Interaction module -type InteractionCommand struct { - Metadata execution.Metadata - Context capability.Context +type CommandInfo struct { + Metadata execution.Metadata + Context capability.Context + OutArgsVariables cacao.Variables } -// The variables returned to SOARCA from a manual interaction -// Alike to the cacao.Variable, but with only type name and value required -type ManualOutArg struct { - Type string `bson:"type,omitempty" json:"type,omitempty" example:"string"` // Type of the variable should be OASIS variable-type-ov - Name string `bson:"name" json:"name" validate:"required" example:"__example_string__"` // The name of the variable in the style __variable_name__ - Description string `bson:"description,omitempty" json:"description,omitempty" example:"some string"` // A description of the variable - Value string `bson:"value" json:"value" validate:"required" 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 - External bool `bson:"external,omitempty" json:"external,omitempty" example:"false"` // Indicate if it's external -} - -// The collection of out args mapped per variable name -type ManualOutArgs map[string]ManualOutArg - -// 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 bool `bson:"response_status" json:"response_status" validate:"required"` // true indicates success, all good. false indicates request not met. - ResponseOutArgs ManualOutArgs `bson:"response_out_args" json:"response_out_args" validate:"required"` // Map of cacao variables expressed as ManualOutArgs, handled in the step out args, with current values and definitions +// 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 } -// The object that the Interaction module presents back to the manual capability +// Object returned to the Interaction object in fulfilment of a manual command type InteractionResponse struct { - ResponseError error - Payload cacao.Variables + Metadata execution.Metadata + ResponseStatus string + ResponseError error + OutArgsVariables cacao.Variables } type ManualCapabilityCommunication struct { - Channel chan InteractionResponse TimeoutContext context.Context -} - -// ################################################################################ -// Data structures for integrations manual command handling -// ################################################################################ - -// As manual interaction integrations are called on go routines, this -// duplications prevents inconsistencies on the objects by forcing -// full deep copies of the objects. - -// The command that the Interactin module notifies to the integrations -type InteractionIntegrationCommand struct { - Metadata execution.Metadata - Context capability.Context -} - -// The payload that an integration puts back on a channel for the Interaction module -// to receive -type InteractionIntegrationResponse struct { - ResponseError error - Payload ManualOutArgsUpdatePayload + Channel chan InteractionResponse } diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index 97d5c123..639222fa 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -7,9 +7,9 @@ import ( "net/http/httptest" api_routes "soarca/pkg/api" manual_api "soarca/pkg/api/manual" + "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "soarca/pkg/models/manual" - manual_model "soarca/pkg/models/manual" "soarca/test/unittest/mocks/mock_interaction_storage" "testing" @@ -28,7 +28,7 @@ func TestGetPendingCommandsCalled(t *testing.T) { recorder := httptest.NewRecorder() api_routes.ManualRoutes(app, manualApiHandler) - mock_interaction_storage.On("GetPendingCommands").Return([]manual_model.InteractionCommandData{}, 200, nil) + mock_interaction_storage.On("GetPendingCommands").Return([]manual.CommandInfo{}, 200, nil) request, err := http.NewRequest("GET", "/manual/", nil) if err != nil { @@ -60,7 +60,7 @@ func TestGetPendingCommandCalled(t *testing.T) { ExecutionId: uuid.MustParse(testExecId), StepId: testStepId, } - testEmptyResponsePendingCommand := manual.InteractionCommandData{} + testEmptyResponsePendingCommand := manual.CommandInfo{} mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(testEmptyResponsePendingCommand, 200, nil) @@ -96,13 +96,14 @@ func TestPostContinueCalled(t *testing.T) { testPlaybookId := "21a4d52c-6efc-4516-a242-dfbc5c89d312" path := "/manual/continue" - testManualResponse := manual_model.ManualOutArgsUpdatePayload{ - Type: "manual-step-response", - ExecutionId: testExecId, - StepId: testStepId, - PlaybookId: testPlaybookId, - ResponseStatus: true, - ResponseOutArgs: manual_model.ManualOutArgs{ + testManualResponse := manual.InteractionResponse{ + Metadata: execution.Metadata{ + ExecutionId: uuid.MustParse(testExecId), + StepId: testStepId, + PlaybookId: testPlaybookId, + }, + ResponseStatus: "success", + OutArgsVariables: cacao.Variables{ "testvar": { Type: "string", Name: "testvar", diff --git a/test/unittest/mocks/mock_interaction/mock_interaction.go b/test/unittest/mocks/mock_interaction/mock_interaction.go index 06bc9582..6d840ad3 100644 --- a/test/unittest/mocks/mock_interaction/mock_interaction.go +++ b/test/unittest/mocks/mock_interaction/mock_interaction.go @@ -11,7 +11,7 @@ type MockInteraction struct { mock.Mock } -func (mock *MockInteraction) Queue(command manual.InteractionCommand, +func (mock *MockInteraction) Queue(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) error { args := mock.Called(command, manualComms) return args.Error(0) diff --git a/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go index ca3df7c7..26f482ce 100644 --- a/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go +++ b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go @@ -11,17 +11,17 @@ type MockInteractionStorage struct { mock.Mock } -func (mock *MockInteractionStorage) GetPendingCommands() ([]manual.InteractionCommandData, int, error) { +func (mock *MockInteractionStorage) GetPendingCommands() ([]manual.CommandInfo, int, error) { args := mock.Called() - return args.Get(0).([]manual.InteractionCommandData), args.Int(1), args.Error(2) + return args.Get(0).([]manual.CommandInfo), args.Int(1), args.Error(2) } -func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.InteractionCommandData, int, error) { +func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, int, error) { args := mock.Called(metadata) - return args.Get(0).(manual.InteractionCommandData), args.Int(1), args.Error(2) + return args.Get(0).(manual.CommandInfo), args.Int(1), args.Error(2) } -func (mock *MockInteractionStorage) PostContinue(outArgsResult manual.ManualOutArgsUpdatePayload) (int, error) { - args := mock.Called(outArgsResult) +func (mock *MockInteractionStorage) PostContinue(response manual.InteractionResponse) (int, error) { + args := mock.Called(response) return args.Int(0), args.Error(1) } From 4fea3ffdebbfb7780cec8c185922a84d382eacaa Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Thu, 16 Jan 2025 17:59:07 +0100 Subject: [PATCH 44/63] fix types in manual api but tests still broken --- pkg/api/manual/manual_api.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 45139de5..882ef11b 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -8,7 +8,6 @@ import ( "soarca/internal/logger" "soarca/pkg/core/capability/manual/interaction" "soarca/pkg/models/api" - apiModel "soarca/pkg/models/api" "soarca/pkg/models/execution" "soarca/pkg/models/manual" @@ -59,7 +58,7 @@ func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandle // @Accept json // @Produce json // @Success 200 {object} api.Execution -// @failure 400 {object} []manual.InteractionCommandData +// @failure 400 {object} []api.InteractionCommandData // @Router /manual/ [GET] func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { commands, status, err := manualHandler.interactionCapability.GetPendingCommands() @@ -84,7 +83,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { // @Produce json // @Param exec_id path string true "execution ID" // @Param step_id path string true "step ID" -// @Success 200 {object} manual.InteractionCommandData +// @Success 200 {object} api.InteractionCommandData // @failure 400 {object} api.Error // @Router /manual/{exec_id}/{step_id} [GET] func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { @@ -120,15 +119,15 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { // @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 manual.ManualOutArgs true "out args" +// @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] @@ -143,7 +142,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } - var outArgsUpdate apiModel.ManualOutArgsUpdatePayload + var outArgsUpdate api.ManualOutArgsUpdatePayload err = json.Unmarshal(jsonData, &outArgsUpdate) if err != nil { log.Error("failed to unmarshal JSON") From 56ec6cd4055d6360b8acc654e2b2d17bffc1d216 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 17 Jan 2025 15:44:06 +0100 Subject: [PATCH 45/63] fix tests after models change --- pkg/api/manual/manual_api.go | 29 ++++-- .../manual/interaction/interaction_test.go | 97 ++++++------------- pkg/core/capability/manual/manual_test.go | 6 +- .../api/routes/manual_api/manual_api_test.go | 18 +++- 4 files changed, 73 insertions(+), 77 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 882ef11b..2b9286a3 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -1,7 +1,9 @@ package manual import ( + "bytes" "encoding/json" + "fmt" "io" "net/http" "reflect" @@ -66,7 +68,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, "Failed get pending manual commands", - "GET /manual/", err.Error()) + "GET /manual/", "") return } g.JSON(status, @@ -94,7 +96,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to parse execution ID", - "GET /manual/"+execution_id+"/"+step_id, err.Error()) + "GET /manual/"+execution_id+"/"+step_id, "") return } @@ -104,7 +106,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, "Failed to provide pending manual command", - "GET /manual/"+execution_id+"/"+step_id, err.Error()) + "GET /manual/"+execution_id+"/"+step_id, "") return } g.JSON(status, @@ -143,7 +145,9 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { } var outArgsUpdate api.ManualOutArgsUpdatePayload - err = json.Unmarshal(jsonData, &outArgsUpdate) + 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, @@ -152,13 +156,26 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } + fmt.Println("Printing parsed body!") + fmt.Println(outArgsUpdate) + + for varName, variable := range outArgsUpdate.ResponseOutArgs { + if varName != variable.Name { + log.Error("variable name mismatch") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Variable name mismatch", + "POST /manual/continue", "") + return + } + } + // Create object to pass to interaction capability executionId, err := uuid.Parse(outArgsUpdate.ExecutionId) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusBadRequest, "Failed to parse execution ID", - "POST /manual/continue", err.Error()) + "POST /manual/continue", "") return } @@ -178,7 +195,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, "Failed to post continue ID", - "POST /manual/continue", err.Error()) + "POST /manual/continue", "") return } g.JSON( diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 60a064b4..f90c8cde 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -163,76 +163,34 @@ func TestGetAllPendingInteractions(t *testing.T) { t.Fail() } - expectedInteractionsJson := ` -[ - { - "type": "test_type", - "execution_id": "61a6c41e-6efc-4516-a242-dfbc5c89d562", - "playbook_id": "test_playbook_id", - "step_id": "test_step_id", - "description": "test_description", - "command": "test_command", - "commandb64": "test_command_b64", - "target": { - "id": "test_id", - "type": "test_type", - "name": "test_name", - "description": "test_description", - "location": {}, - "contact": {} - }, - "out_args": { - "var2": { - "type": "string", - "name": "var2", - "description": "test variable", - "value": "test_value_2" - } - } - }, - { - "type": "test_type", - "execution_id": "50b6d52c-6efc-4516-a242-dfbc5c89d421", - "playbook_id": "test_playbook_id", - "step_id": "test_step_id", - "description": "test_description", - "command": "test_command", - "commandb64": "test_command_b64", - "target": { - "id": "test_id", - "type": "test_type", - "name": "test_name", - "description": "test_description", - "location": {}, - "contact": {} - }, - "out_args": { - "var2": { - "type": "string", - "name": "var2", - "description": "test variable", - "value": "test_value_2" - } - } - } -] - ` - var expectedInteractions []manualModel.InteractionResponse - err = json.Unmarshal([]byte(expectedInteractionsJson), &expectedInteractions) + expectedInteractions := []manualModel.CommandInfo{testInteractionCommand, testNewInteractionCommand} + + receivedInteractions := interaction.getAllPendingInteractions() + receivedInteractionsJson, err := json.MarshalIndent(receivedInteractions, "", " ") if err != nil { + t.Log("failed to marshal received interactions") t.Log(err) t.Fail() } - t.Log("expected interactions") - t.Log(expectedInteractions) + fmt.Println("received interactions") + fmt.Println(string(receivedInteractionsJson)) - receivedInteractions := interaction.getAllPendingInteractions() - fmt.Println(receivedInteractions) - - if !reflect.DeepEqual(expectedInteractions, receivedInteractions) { - err = fmt.Errorf("expected %v, but got %v", expectedInteractions, receivedInteractions) - t.Log(err) - t.Fail() + 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() + } } } @@ -332,7 +290,7 @@ func TestPostContinueWarningsRaised(t *testing.T) { } outArg := cacao.Variable{ - Type: "string", + Type: "banana", Name: "var2", Description: "this description will not make it to the returned var", Value: "now the value is bananas", @@ -358,7 +316,8 @@ func TestPostContinueWarningsRaised(t *testing.T) { expectedLogEntry1 := "provided out arg var2 is attempting to change 'Constant' property" expectedLogEntry2 := "provided out arg var2 is attempting to change 'Description' property" expectedLogEntry3 := "provided out arg var2 is attempting to change 'External' property" - expectedLogs := []string{expectedLogEntry1, expectedLogEntry2, expectedLogEntry3} + expectedLogEntry4 := "provided out arg var2 is attempting to change 'Type' property" + expectedLogs := []string{expectedLogEntry1, expectedLogEntry2, expectedLogEntry3, expectedLogEntry4} all := true for _, expectedMessage := range expectedLogs { @@ -625,9 +584,9 @@ var testMetadata = execution.Metadata{ var testInteractionCommand = manualModel.CommandInfo{ Metadata: testMetadata, OutArgsVariables: cacao.Variables{ - "var1": { + "var2": { Type: "string", - Name: "var1", + Name: "var2", Description: "test variable", Value: "", Constant: false, diff --git a/pkg/core/capability/manual/manual_test.go b/pkg/core/capability/manual/manual_test.go index 2f83f594..038fe644 100644 --- a/pkg/core/capability/manual/manual_test.go +++ b/pkg/core/capability/manual/manual_test.go @@ -23,7 +23,11 @@ func TestManualExecution(t *testing.T) { meta := execution.Metadata{} commandContext := capability.Context{} - command := manualModel.CommandInfo{} + command := manualModel.CommandInfo{ + Metadata: execution.Metadata{}, + Context: capability.Context{}, + OutArgsVariables: cacao.NewVariables(), + } // Capture the channel passed to Queue diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index 639222fa..5bd6aba1 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -96,6 +97,21 @@ func TestPostContinueCalled(t *testing.T) { 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), @@ -111,7 +127,7 @@ func TestPostContinueCalled(t *testing.T) { }, }, } - jsonData, err := json.Marshal(testManualResponse) + jsonData, err := json.Marshal(testManualUpdatePayload) if err != nil { t.Fatalf("Error marshalling JSON: %v", err) } From 8b8931c2863d674920609801a4dd22fcddd3d0bb Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:28:53 +0100 Subject: [PATCH 46/63] revert API base64 and responsestatus types --- .../en/docs/core-components/api-manual.md | 10 +-- pkg/api/manual/manual_api.go | 43 ++++++++++--- pkg/models/api/manual.go | 34 +++++----- pkg/models/manual/manual.go | 9 ++- .../api/routes/manual_api/manual_api_test.go | 63 +++++++++++++++++-- 5 files changed, 127 insertions(+), 32 deletions(-) diff --git a/docs/content/en/docs/core-components/api-manual.md b/docs/content/en/docs/core-components/api-manual.md index f8602148..aa430bb6 100644 --- a/docs/content/en/docs/core-components/api-manual.md +++ b/docs/content/en/docs/core-components/api-manual.md @@ -46,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 -|commandBase64 |command |string |The command in base 64 if present -|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 @@ -110,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 @@ -164,7 +164,7 @@ 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 |true / false |string |`true` indicates successfull fulfilment of the manual request. `false` indicates failed satisfaction of request +|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 @@ -176,7 +176,7 @@ 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": "", diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 2b9286a3..b7f1813c 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -3,7 +3,6 @@ package manual import ( "bytes" "encoding/json" - "fmt" "io" "net/http" "reflect" @@ -71,8 +70,14 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { "GET /manual/", "") return } + + response := []api.InteractionCommandData{} + for _, command := range commands { + response = append(response, manualHandler.parseCommandInfoToResponse(command)) + } + g.JSON(status, - commands) + response) } // manual @@ -109,8 +114,10 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { "GET /manual/"+execution_id+"/"+step_id, "") return } - g.JSON(status, - commandData) + + commandInfo := manualHandler.parseCommandInfoToResponse(commandData) + + g.JSON(status, commandInfo) } // manual @@ -156,9 +163,6 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } - fmt.Println("Printing parsed body!") - fmt.Println(outArgsUpdate) - for varName, variable := range outArgsUpdate.ResponseOutArgs { if varName != variable.Name { log.Error("variable name mismatch") @@ -205,3 +209,28 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { 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 +} diff --git a/pkg/models/api/manual.go b/pkg/models/api/manual.go index 4b9c67f9..35b3acd4 100644 --- a/pkg/models/api/manual.go +++ b/pkg/models/api/manual.go @@ -1,27 +1,31 @@ package api -import "soarca/pkg/models/cacao" +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 - CommandBase64 string `bson:"commandb64,omitempty" json:"commandb64,omitempty"` // The command in b64 if present - 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 + 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 string `bson:"response_status" json:"response_status" validate:"required"` // true indicates success, all good. false indicates request not met. + 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/manual/manual.go b/pkg/models/manual/manual.go index f1de2def..703bf7d4 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -11,6 +11,13 @@ import ( // 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 @@ -33,7 +40,7 @@ type InteractionIntegrationCommand struct { // Object returned to the Interaction object in fulfilment of a manual command type InteractionResponse struct { Metadata execution.Metadata - ResponseStatus string + ResponseStatus ManualResponseStatus ResponseError error OutArgsVariables cacao.Variables } diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index 5bd6aba1..06d64d50 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -12,6 +12,7 @@ import ( "soarca/pkg/models/execution" "soarca/pkg/models/manual" "soarca/test/unittest/mocks/mock_interaction_storage" + "strings" "testing" "github.com/gin-gonic/gin" @@ -61,9 +62,13 @@ func TestGetPendingCommandCalled(t *testing.T) { ExecutionId: uuid.MustParse(testExecId), StepId: testStepId, } - testEmptyResponsePendingCommand := manual.CommandInfo{} + testEmptyResponsePendingCommand := apiModel.InteractionCommandData{ + Type: "manual-command-info", + ExecutionId: "00000000-0000-0000-0000-000000000000", + } + emptyCommandInfoList := manual.CommandInfo{} - mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(testEmptyResponsePendingCommand, 200, nil) + mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(emptyCommandInfoList, 200, nil) request, err := http.NewRequest("GET", path, nil) if err != nil { @@ -71,11 +76,14 @@ func TestGetPendingCommandCalled(t *testing.T) { } app.ServeHTTP(recorder, request) - expectedData := testEmptyResponsePendingCommand - expectedJSON, err := json.Marshal(expectedData) + + 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) @@ -145,3 +153,50 @@ func TestPostContinueCalled(t *testing.T) { mock_interaction_storage.AssertExpectations(t) } + +func TestPostContinueFailsOnInvalidVariable(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() + } + + app.ServeHTTP(recorder, request) + t.Log(recorder.Body.String()) + assert.Equal(t, 400, recorder.Code) + assert.Equal(t, true, strings.Contains(recorder.Body.String(), "Variable name mismatch")) + + mock_interaction_storage.AssertExpectations(t) +} From 9594f7995dd8075f071093879e9dd9fe5a34803e Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:35:54 +0100 Subject: [PATCH 47/63] change interaction object warnings on unauthorized arg properties edits --- pkg/core/capability/manual/interaction/interaction.go | 8 ++++---- .../capability/manual/interaction/interaction_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 562fffa1..41213452 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -170,16 +170,16 @@ func (manualController *InteractionController) PostContinue(response manual.Inte // then warn if any value outside "value" has changed if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { if variable.Constant != pending.Constant { - log.Warningf("provided out arg %s is attempting to change 'Constant' property", varName) + log.Warningf("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 { - log.Warningf("provided out arg %s is attempting to change 'Description' property", varName) + log.Warningf("provided out arg %s has a different value for 'Description' property of intended out arg. This different value is ignored.", varName) } if variable.External != pending.External { - log.Warningf("provided out arg %s is attempting to change 'External' property", varName) + log.Warningf("provided out arg %s has a different value for 'External' property of intended out arg. This different value is ignored.", varName) } if variable.Type != pending.Type { - log.Warningf("provided out arg %s is attempting to change 'Type' property", varName) + log.Warningf("provided out arg %s has a different value for 'Type' property of intended out arg. This different value is ignored.", varName) } } } diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index f90c8cde..8000acf8 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -313,10 +313,10 @@ func TestPostContinueWarningsRaised(t *testing.T) { assert.Equal(t, statusCode, expectedStatusCode) assert.Equal(t, err, expectedErr) - expectedLogEntry1 := "provided out arg var2 is attempting to change 'Constant' property" - expectedLogEntry2 := "provided out arg var2 is attempting to change 'Description' property" - expectedLogEntry3 := "provided out arg var2 is attempting to change 'External' property" - expectedLogEntry4 := "provided out arg var2 is attempting to change 'Type' property" + 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 a different value for 'Description' property of intended out arg. This different value is ignored." + expectedLogEntry3 := "provided out arg var2 has a different value for 'External' property of intended out arg. This different value is ignored." + expectedLogEntry4 := "provided out arg var2 has a different value for 'Type' property of intended out arg. This different value is ignored." expectedLogs := []string{expectedLogEntry1, expectedLogEntry2, expectedLogEntry3, expectedLogEntry4} all := true From ad81cb6135de2832bbf96d0d5e684d68f7815db4 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:24:14 +0100 Subject: [PATCH 48/63] refactor interaction to simpler architecture --- pkg/api/manual/manual_api.go | 12 +- .../manual/interaction/interaction.go | 137 +++++++++--------- .../manual/interaction/interaction_test.go | 10 +- pkg/models/utils/context/context.go | 4 + .../mock_interaction_storage.go | 12 +- 5 files changed, 88 insertions(+), 87 deletions(-) create mode 100644 pkg/models/utils/context/context.go diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index b7f1813c..f8656ea9 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -62,7 +62,7 @@ func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandle // @failure 400 {object} []api.InteractionCommandData // @Router /manual/ [GET] func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { - commands, status, err := manualHandler.interactionCapability.GetPendingCommands() + commands, err := manualHandler.interactionCapability.GetPendingCommands() if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -76,7 +76,7 @@ func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { response = append(response, manualHandler.parseCommandInfoToResponse(command)) } - g.JSON(status, + g.JSON(http.StatusOK, response) } @@ -106,7 +106,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { } executionMetadata := execution.Metadata{ExecutionId: execId, StepId: step_id} - commandData, status, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) + commandData, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -117,7 +117,7 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { commandInfo := manualHandler.parseCommandInfoToResponse(commandData) - g.JSON(status, commandInfo) + g.JSON(http.StatusOK, commandInfo) } // manual @@ -194,7 +194,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { ResponseError: nil, } - status, err := manualHandler.interactionCapability.PostContinue(interactionResponse) + err = manualHandler.interactionCapability.PostContinue(interactionResponse) if err != nil { log.Error(err) apiError.SendErrorResponse(g, http.StatusInternalServerError, @@ -203,7 +203,7 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } g.JSON( - status, + http.StatusOK, api.Execution{ ExecutionId: uuid.MustParse(outArgsUpdate.ExecutionId), PlaybookId: outArgsUpdate.PlaybookId, diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 41213452..1854d534 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -3,11 +3,12 @@ package interaction import ( "errors" "fmt" - "net/http" "reflect" "soarca/internal/logger" + "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "soarca/pkg/models/manual" + ctxModel "soarca/pkg/models/utils/context" ) // TODO @@ -47,10 +48,10 @@ type ICapabilityInteraction interface { } type IInteractionStorage interface { - GetPendingCommands() ([]manual.CommandInfo, int, error) + 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, int, error) - PostContinue(response manual.InteractionResponse) (int, error) + GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) + PostContinue(response manual.InteractionResponse) error } type InteractionController struct { @@ -67,8 +68,6 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr } // TODO: -// - Change registration of command data to only keep InteractionCommand object, move InteractionCommandData to api model -// - Add interactionintegration channel in interaction storage entry // - Add check on timeoutcontext.Done() for timeout (vs completion), and remove entry from pending in that case // - Change waitInteractionIntegrationResponse to be waitResponse // - Put result := <- interactionintegrationchannel into a separate function @@ -98,38 +97,32 @@ func (manualController *InteractionController) Queue(command manual.CommandInfo, go notifier.Notify(integrationCommand, integrationChannel) } - // Async idle wait on interaction integration channel - go manualController.waitInteractionIntegrationResponse(manualComms, integrationChannel) + // Async idle wait on command-specific channel closure + go manualController.handleManualCommandResponse(command, manualComms) return nil } -func (manualController *InteractionController) waitInteractionIntegrationResponse(manualComms manual.ManualCapabilityCommunication, integrationChannel chan manual.InteractionResponse) { - defer close(integrationChannel) - for { - select { - case <-manualComms.TimeoutContext.Done(): - log.Info("context canceled due to response or timeout. exiting goroutine") - return - - case <-manualComms.Channel: - log.Info("detected activity on manual capability channel. exiting goroutine without consuming the message") - return - - case result := <-integrationChannel: - // Check register for pending manual command - // Remove interaction from pending ones - err := manualController.removeInteractionFromPending(result.Metadata) +func (manualController *InteractionController) handleManualCommandResponse(command manual.CommandInfo, manualComms manual.ManualCapabilityCommunication) { + select { + case <-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 { - // If it was not there, was already resolved log.Warning(err) - // Captured if channel not yet closed log.Warning("manual command not found among pending ones. should be already resolved") return } - - manualComms.Channel <- result - return } } } @@ -137,69 +130,49 @@ func (manualController *InteractionController) waitInteractionIntegrationRespons // ############################################################################ // IInteractionStorage implementation // ############################################################################ -func (manualController *InteractionController) GetPendingCommands() ([]manual.CommandInfo, int, error) { +func (manualController *InteractionController) GetPendingCommands() ([]manual.CommandInfo, error) { log.Trace("getting pending manual commands") - return manualController.getAllPendingInteractions(), http.StatusOK, nil + return manualController.getAllPendingInteractions(), nil } -func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, int, error) { +func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) { log.Trace("getting pending manual command") interaction, err := manualController.getPendingInteraction(metadata) - // TODO: determine status code - return interaction.CommandInfo, http.StatusOK, err + return interaction.CommandInfo, err } -func (manualController *InteractionController) PostContinue(response manual.InteractionResponse) (int, error) { +func (manualController *InteractionController) PostContinue(response manual.InteractionResponse) error { log.Trace("completing manual command") - // If not in there, it means it was already solved (right?) + // 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 http.StatusAlreadyReported, err + return err } - // If it is - for varName, variable := range response.OutArgsVariables { - // first check that out args provided match the variables - if _, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; !ok { - err := errors.New("provided out args do not match command-related variables") - log.Warning("provided out args do not match command-related variables") - return http.StatusBadRequest, err - } - // then warn if any value outside "value" has changed - if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { - if variable.Constant != pending.Constant { - log.Warningf("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 { - log.Warningf("provided out arg %s has a different value for 'Description' property of intended out arg. This different value is ignored.", varName) - } - if variable.External != pending.External { - log.Warningf("provided out arg %s has a different value for 'External' property of intended out arg. This different value is ignored.", varName) - } - if variable.Type != pending.Type { - log.Warningf("provided out arg %s has a different value for 'Type' property of intended out arg. This different value is ignored.", varName) - } - } + 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 - returnedVars := response.OutArgsVariables log.Trace("pushing assigned variables in manual capability channel") pendingEntry.Channel <- manual.InteractionResponse{ + Metadata: response.Metadata, ResponseError: nil, - OutArgsVariables: returnedVars, + ResponseStatus: response.ResponseStatus, + OutArgsVariables: response.OutArgsVariables, } - // de-register the command - err = manualController.removeInteractionFromPending(response.Metadata) - if err != nil { - log.Error(err) - return http.StatusInternalServerError, err + + if len(warnings) > 0 { + for _, warning := range warnings { + log.Warning(warning) + } } - return http.StatusOK, nil + return nil } // ############################################################################ @@ -290,3 +263,31 @@ func (manualController *InteractionController) removeInteractionFromPending(comm } 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 { + warns = append(warns, fmt.Sprintf("provided out arg %s does not match any intended out arg", varName)) + err = errors.New("provided out args do not match command-related variables") + } + // 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 index 8000acf8..a94c9456 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -305,12 +305,10 @@ func TestPostContinueWarningsRaised(t *testing.T) { OutArgsVariables: cacao.Variables{"var2": outArg}, } - statusCode, err := interaction.PostContinue(outArgsUpdate) + err = interaction.PostContinue(outArgsUpdate) - expectedStatusCode := 200 - var expectedErr error + var expectedErr error = nil - assert.Equal(t, statusCode, expectedStatusCode) assert.Equal(t, err, expectedErr) expectedLogEntry1 := "provided out arg var2 has different value for 'Constant' property of intended out arg. This different value is ignored." @@ -378,9 +376,8 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { OutArgsVariables: cacao.Variables{"testNotExisting": outArg}, } - statusCode, err := interaction.PostContinue(outArgsUpdate) + err = interaction.PostContinue(outArgsUpdate) - expectedStatusCode := 400 expectedErr := errors.New("provided out args do not match command-related variables") expectedLogEntry1 := "provided out args do not match command-related variables" @@ -405,7 +402,6 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { } } - assert.Equal(t, statusCode, expectedStatusCode) assert.Equal(t, err, expectedErr) assert.NotEqual(t, len(hook.Entries), 0) 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/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go index 26f482ce..65ccaaef 100644 --- a/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go +++ b/test/unittest/mocks/mock_interaction_storage/mock_interaction_storage.go @@ -11,17 +11,17 @@ type MockInteractionStorage struct { mock.Mock } -func (mock *MockInteractionStorage) GetPendingCommands() ([]manual.CommandInfo, int, error) { +func (mock *MockInteractionStorage) GetPendingCommands() ([]manual.CommandInfo, error) { args := mock.Called() - return args.Get(0).([]manual.CommandInfo), args.Int(1), args.Error(2) + return args.Get(0).([]manual.CommandInfo), args.Error(1) } -func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, int, error) { +func (mock *MockInteractionStorage) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) { args := mock.Called(metadata) - return args.Get(0).(manual.CommandInfo), args.Int(1), args.Error(2) + return args.Get(0).(manual.CommandInfo), args.Error(1) } -func (mock *MockInteractionStorage) PostContinue(response manual.InteractionResponse) (int, error) { +func (mock *MockInteractionStorage) PostContinue(response manual.InteractionResponse) error { args := mock.Called(response) - return args.Int(0), args.Error(1) + return args.Error(0) } From 58782983084b5a7668d729e5b1fae6cef47563bb Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:28:59 +0100 Subject: [PATCH 49/63] started to fix tests again but more to do --- pkg/core/capability/manual/interaction/interaction_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index a94c9456..6e2df047 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -72,7 +72,7 @@ func TestQueueExitOnTimeout(t *testing.T) { time.Sleep(50 * time.Millisecond) - expectedLogEntry := "context canceled due to response or timeout. exiting goroutine" + expectedLogEntry := "manual command timed out. deregistering associated pending command" assert.NotEqual(t, len(hook.Entries), 0) assert.Equal(t, strings.Contains(hook.Entries[0].Message, expectedLogEntry), true) From 0d6245b1b2d04c875a33a74832eeb6f0f08e2a65 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:33:33 +0100 Subject: [PATCH 50/63] fix tests --- .../manual/interaction/interaction.go | 18 +++--- .../manual/interaction/interaction_test.go | 55 +++++++------------ .../api/routes/manual_api/manual_api_test.go | 6 +- 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 1854d534..935922b7 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -72,6 +72,7 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // - Change waitInteractionIntegrationResponse to be waitResponse // - Put result := <- interactionintegrationchannel into a separate function // - Just use the one instance of manual capability channel. Do not use interactionintegrationchannel +// - Create typed error and pass back to API function for Storage interface fcns // ############################################################################ // ICapabilityInteraction implementation @@ -104,6 +105,13 @@ func (manualController *InteractionController) Queue(command manual.CommandInfo, } 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)) + select { case <-manualComms.TimeoutContext.Done(): if manualComms.TimeoutContext.Err().Error() == ctxModel.ErrorContextTimeout { @@ -159,12 +167,7 @@ func (manualController *InteractionController) PostContinue(response manual.Inte //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 <- manual.InteractionResponse{ - Metadata: response.Metadata, - ResponseError: nil, - ResponseStatus: response.ResponseStatus, - OutArgsVariables: response.OutArgsVariables, - } + pendingEntry.Channel <- response if len(warnings) > 0 { for _, warning := range warnings { @@ -270,8 +273,7 @@ func (manualController *InteractionController) validateMatchingOutArgs(pendingEn for varName, variable := range responseOutArgs { // first check that out args provided match the variables if _, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; !ok { - warns = append(warns, fmt.Sprintf("provided out arg %s does not match any intended out arg", varName)) - err = errors.New("provided out args do not match command-related variables") + err = errors.New(fmt.Sprintf("provided out arg %s does not match any intended out arg", varName)) } // then warn if any value outside "value" has changed if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 6e2df047..c3638a57 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -269,11 +269,9 @@ func TestCopyOutArgsToVars(t *testing.T) { func TestPostContinueWarningsRaised(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) - timeout := 500 * time.Millisecond + timeout := 5 * time.Second testCtx, testCancel := context.WithTimeout(context.Background(), timeout) - defer testCancel() - hook := NewTestLogHook() log.Logger.AddHook(hook) @@ -281,7 +279,6 @@ func TestPostContinueWarningsRaised(t *testing.T) { Channel: make(chan manualModel.InteractionResponse), TimeoutContext: testCtx, } - defer close(testCapComms.Channel) err := interaction.Queue(testInteractionCommand, testCapComms) if err != nil { @@ -305,27 +302,38 @@ func TestPostContinueWarningsRaised(t *testing.T) { OutArgsVariables: cacao.Variables{"var2": outArg}, } - err = interaction.PostContinue(outArgsUpdate) + // 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 assert.Equal(t, err, expectedErr) + // Simulating Manual Capability closing the channel and the context + close(testCapComms.Channel) + testCancel() + time.Sleep(800 * 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 a different value for 'Description' property of intended out arg. This different value is ignored." - expectedLogEntry3 := "provided out arg var2 has a different value for 'External' property of intended out arg. This different value is ignored." - expectedLogEntry4 := "provided out arg var2 has a different value for 'Type' 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(expectedMessage, entry.Message) { + if strings.Contains(entry.Message, expectedMessage) { containsAll = true break } - if !strings.Contains(expectedMessage, entry.Message) { + if !strings.Contains(entry.Message, expectedMessage) { containsAll = false } } @@ -378,34 +386,9 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { err = interaction.PostContinue(outArgsUpdate) - expectedErr := errors.New("provided out args do not match command-related variables") - - expectedLogEntry1 := "provided out args do not match command-related variables" - expectedLogs := []string{expectedLogEntry1} - - all := true - for _, expectedMessage := range expectedLogs { - containsAll := true - for _, entry := range hook.Entries { - if strings.Contains(expectedMessage, entry.Message) { - containsAll = true - break - } - if !strings.Contains(expectedMessage, entry.Message) { - containsAll = false - } - } - if !containsAll { - t.Logf("log message: '%s' not found in logged messages", expectedMessage) - all = false - break - } - } + expectedErr := errors.New(fmt.Sprintf("provided out arg %s does not match any intended out arg", outArg.Name)) assert.Equal(t, err, expectedErr) - - assert.NotEqual(t, len(hook.Entries), 0) - assert.Equal(t, all, true) } func TestRegisterRetrieveNewExecutionNewPendingInteraction(t *testing.T) { diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index 06d64d50..28295354 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -30,7 +30,7 @@ func TestGetPendingCommandsCalled(t *testing.T) { recorder := httptest.NewRecorder() api_routes.ManualRoutes(app, manualApiHandler) - mock_interaction_storage.On("GetPendingCommands").Return([]manual.CommandInfo{}, 200, nil) + mock_interaction_storage.On("GetPendingCommands").Return([]manual.CommandInfo{}, nil) request, err := http.NewRequest("GET", "/manual/", nil) if err != nil { @@ -68,7 +68,7 @@ func TestGetPendingCommandCalled(t *testing.T) { } emptyCommandInfoList := manual.CommandInfo{} - mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(emptyCommandInfoList, 200, nil) + mock_interaction_storage.On("GetPendingCommand", executionMetadata).Return(emptyCommandInfoList, nil) request, err := http.NewRequest("GET", path, nil) if err != nil { @@ -140,7 +140,7 @@ func TestPostContinueCalled(t *testing.T) { t.Fatalf("Error marshalling JSON: %v", err) } - mock_interaction_storage.On("PostContinue", testManualResponse).Return(200, nil) + mock_interaction_storage.On("PostContinue", testManualResponse).Return(nil) request, err := http.NewRequest("POST", path, bytes.NewBuffer(jsonData)) if err != nil { From 581df4b9d2fe8e5fcb667e486ceb92aae9e5174f Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:48:07 +0100 Subject: [PATCH 51/63] fix lint --- .../manual/interaction/interaction.go | 58 ++++++------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 935922b7..fdbbe250 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -11,25 +11,6 @@ import ( ctxModel "soarca/pkg/models/utils/context" ) -// TODO -// Add manual capability to action execution, - -// NOTE: current outArgs management for Manual commands: -// - The decomposer passes the PlaybookStepMetadata object to the -// action executor, which includes Step -// - The action executor calls Execute on the capability (command type) -// passing capability.Context, which includes the Step object -// - The manual capability calls Queue passing InteractionCommand, -// which includes capability.Context -// - Queue() posts a message, which shall include the text of the manual command, -// and the varibales (outArgs) expected -// - registerPendingInteraction records the CACAO Variables corresponding to the -// outArgs field (in the step. In future, in the command) -// - A manual response posts back a map[string]manual.ManualOutArg object, -// which is exactly like cacao variables, but with different requested fields. -// - The Interaction object cleans the returned variables to only keep -// the name, type, and value (to not overwrite other fields) - type Empty struct{} var component = reflect.TypeOf(Empty{}).PkgPath() @@ -68,10 +49,6 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr } // TODO: -// - Add check on timeoutcontext.Done() for timeout (vs completion), and remove entry from pending in that case -// - Change waitInteractionIntegrationResponse to be waitResponse -// - Put result := <- interactionintegrationchannel into a separate function -// - Just use the one instance of manual capability channel. Do not use interactionintegrationchannel // - Create typed error and pass back to API function for Storage interface fcns // ############################################################################ @@ -112,25 +89,24 @@ func (manualController *InteractionController) handleManualCommandResponse(comma fmt.Sprintf( "goroutine handling command response %s, %s has ended", command.Metadata.ExecutionId.String(), command.Metadata.StepId)) - select { - case <-manualComms.TimeoutContext.Done(): - if manualComms.TimeoutContext.Err().Error() == ctxModel.ErrorContextTimeout { - log.Info("manual command timed out. deregistering associated pending command") + // 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 - } + 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 } } } From 36c7e3e05c78dba02ec8ebd41c534651c7bc6973 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:50:55 +0100 Subject: [PATCH 52/63] onelined manualhandler init in manual api --- pkg/api/manual/manual_api.go | 4 +--- pkg/core/capability/manual/interaction/interaction.go | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index f8656ea9..cd406254 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -45,9 +45,7 @@ type ManualHandler struct { } func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandler { - instance := ManualHandler{} - instance.interactionCapability = interaction - return &instance + return &ManualHandler{interactionCapability: interaction} } // manual diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index fdbbe250..629a1ff2 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -50,6 +50,7 @@ func New(manualIntegrations []IInteractionIntegrationNotifier) *InteractionContr // TODO: // - Create typed error and pass back to API function for Storage interface fcns +// - Fix documeentation // ############################################################################ // ICapabilityInteraction implementation From 83f70502798b084c8ac5ec2658c3ad87c93ec511 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:52:35 +0100 Subject: [PATCH 53/63] fix lint --- pkg/core/capability/manual/interaction/interaction.go | 2 +- pkg/core/capability/manual/interaction/interaction_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 629a1ff2..f4987cb5 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -250,7 +250,7 @@ func (manualController *InteractionController) validateMatchingOutArgs(pendingEn for varName, variable := range responseOutArgs { // first check that out args provided match the variables if _, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; !ok { - err = errors.New(fmt.Sprintf("provided out arg %s does not match any intended out arg", varName)) + err = fmt.Errorf("provided out arg %s does not match any intended out arg", varName) } // then warn if any value outside "value" has changed if pending, ok := pendingEntry.CommandInfo.OutArgsVariables[varName]; ok { diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index c3638a57..75872b0f 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -386,7 +386,7 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { err = interaction.PostContinue(outArgsUpdate) - expectedErr := errors.New(fmt.Sprintf("provided out arg %s does not match any intended out arg", outArg.Name)) + expectedErr := fmt.Errorf("provided out arg %s does not match any intended out arg", outArg.Name) assert.Equal(t, err, expectedErr) } From 9b1533058d247e0171a7065bf76ae0e7e74f1ba4 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:15:30 +0100 Subject: [PATCH 54/63] improve clarity get command info interaction functions --- .../manual/interaction/interaction.go | 4 ++-- .../manual/interaction/interaction_test.go | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index f4987cb5..2a54991d 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -117,7 +117,7 @@ func (manualController *InteractionController) handleManualCommandResponse(comma // ############################################################################ func (manualController *InteractionController) GetPendingCommands() ([]manual.CommandInfo, error) { log.Trace("getting pending manual commands") - return manualController.getAllPendingInteractions(), nil + return manualController.getAllPendingCommandsInfo(), nil } func (manualController *InteractionController) GetPendingCommand(metadata execution.Metadata) (manual.CommandInfo, error) { @@ -199,7 +199,7 @@ func (manualController *InteractionController) registerPendingInteraction(comman return nil } -func (manualController *InteractionController) getAllPendingInteractions() []manual.CommandInfo { +func (manualController *InteractionController) getAllPendingCommandsInfo() []manual.CommandInfo { allPendingInteractions := []manual.CommandInfo{} for _, interactions := range manualController.InteractionStorage { for _, interaction := range interactions { diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 75872b0f..dfdaa0b6 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -165,7 +165,7 @@ func TestGetAllPendingInteractions(t *testing.T) { expectedInteractions := []manualModel.CommandInfo{testInteractionCommand, testNewInteractionCommand} - receivedInteractions := interaction.getAllPendingInteractions() + receivedInteractions := interaction.getAllPendingCommandsInfo() receivedInteractionsJson, err := json.MarshalIndent(receivedInteractions, "", " ") if err != nil { t.Log("failed to marshal received interactions") @@ -413,6 +413,26 @@ func TestRegisterRetrieveNewExecutionNewPendingInteraction(t *testing.T) { } } +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 := errors.New( + "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) From 36444ee38130801ec7dd8a1ef35ff80229dae47e Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:46:23 +0100 Subject: [PATCH 55/63] implement error typing and api check for manual interaction storage iface --- pkg/api/manual/manual_api.go | 20 +++++++++++--- .../manual/interaction/interaction.go | 11 +++++--- .../manual/interaction/interaction_test.go | 27 ++++++++++--------- pkg/models/manual/manual.go | 17 ++++++++++++ 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index cd406254..85fb1670 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -3,6 +3,7 @@ package manual import ( "bytes" "encoding/json" + "errors" "io" "net/http" "reflect" @@ -107,7 +108,11 @@ func (manualHandler *ManualHandler) GetPendingCommand(g *gin.Context) { commandData, err := manualHandler.interactionCapability.GetPendingCommand(executionMetadata) if err != nil { log.Error(err) - apiError.SendErrorResponse(g, http.StatusInternalServerError, + 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 @@ -195,8 +200,17 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { err = manualHandler.interactionCapability.PostContinue(interactionResponse) if err != nil { log.Error(err) - apiError.SendErrorResponse(g, http.StatusInternalServerError, - "Failed to post continue ID", + code := http.StatusBadRequest + msg := "Failed to post continue ID" + 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 } diff --git a/pkg/core/capability/manual/interaction/interaction.go b/pkg/core/capability/manual/interaction/interaction.go index 2a54991d..cfa69c93 100644 --- a/pkg/core/capability/manual/interaction/interaction.go +++ b/pkg/core/capability/manual/interaction/interaction.go @@ -212,16 +212,17 @@ func (manualController *InteractionController) getAllPendingCommandsInfo() []man func (manualController *InteractionController) getPendingInteraction(commandMetadata execution.Metadata) (manual.InteractionStorageEntry, error) { executionCommands, ok := manualController.InteractionStorage[commandMetadata.ExecutionId.String()] if !ok { - err := fmt.Errorf("no pending commands found for execution %s", commandMetadata.ExecutionId.String()) - return manual.InteractionStorageEntry{}, err + 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.Errorf("no pending commands found for execution %s -> step %s", + err := fmt.Sprintf("no pending commands found for execution %s -> step %s", commandMetadata.ExecutionId.String(), commandMetadata.StepId, ) - return manual.InteractionStorageEntry{}, err + return manual.InteractionStorageEntry{}, manual.ErrorPendingCommandNotFound{Err: err} + } return interaction, nil } @@ -251,6 +252,8 @@ func (manualController *InteractionController) validateMatchingOutArgs(pendingEn // 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 { diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index dfdaa0b6..ff00ecdd 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -386,7 +386,8 @@ func TestPostContinueFailOnNonexistingVariable(t *testing.T) { err = interaction.PostContinue(outArgsUpdate) - expectedErr := fmt.Errorf("provided out arg %s does not match any intended out arg", outArg.Name) + expectedErr := manualModel.ErrorNonMatchingOutArgs{ + Err: fmt.Sprintf("provided out arg %s does not match any intended out arg", outArg.Name)} assert.Equal(t, err, expectedErr) } @@ -424,10 +425,10 @@ func TestGetEmptyPendingCommand(t *testing.T) { t.Fail() } - expectedErr := errors.New( - "no pending commands found for execution " + + 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) @@ -473,9 +474,9 @@ func TestFailOnRetrieveUnexistingExecutionInteraction(t *testing.T) { t.Fail() } - expectedErr := errors.New( - "no pending commands found for execution 50b6d52c-6efc-4516-a242-dfbc5c89d421", - ) + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution 50b6d52c-6efc-4516-a242-dfbc5c89d421", + } assert.Equal(t, err, expectedErr) } @@ -500,11 +501,11 @@ func TestFailOnRetrieveNonExistingCommandInteraction(t *testing.T) { t.Fail() } - expectedErr := errors.New( - "no pending commands found for execution " + + 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) } @@ -545,10 +546,10 @@ func TestRemovePendingInteraciton(t *testing.T) { t.Fail() } - expectedErr := errors.New( - "no pending commands found for execution " + + expectedErr := manualModel.ErrorPendingCommandNotFound{ + Err: "no pending commands found for execution " + "61a6c41e-6efc-4516-a242-dfbc5c89d562", - ) + } assert.Equal(t, err, expectedErr) } diff --git a/pkg/models/manual/manual.go b/pkg/models/manual/manual.go index 703bf7d4..6fa387f6 100644 --- a/pkg/models/manual/manual.go +++ b/pkg/models/manual/manual.go @@ -49,3 +49,20 @@ 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 +} From fca8e06a137e56f3f42c9e1b5386cc3c4b7e1210 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:01:52 +0100 Subject: [PATCH 56/63] slight improvement unit interface --- pkg/api/manual/manual_api.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 85fb1670..eb33e96c 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -10,6 +10,7 @@ import ( "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" @@ -166,15 +167,8 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { return } - for varName, variable := range outArgsUpdate.ResponseOutArgs { - if varName != variable.Name { - log.Error("variable name mismatch") - apiError.SendErrorResponse(g, http.StatusBadRequest, - "Variable name mismatch", - "POST /manual/continue", "") - return - } - } + // Check if variable names match + manualHandler.postContinueVariableNamesMatchCheck(outArgsUpdate.ResponseOutArgs, g) // Create object to pass to interaction capability executionId, err := uuid.Parse(outArgsUpdate.ExecutionId) @@ -246,3 +240,15 @@ func (manualHandler *ManualHandler) parseCommandInfoToResponse(commandInfo manua return response } + +func (ManualHandler *ManualHandler) postContinueVariableNamesMatchCheck(outArgs cacao.Variables, g *gin.Context) { + for varName, variable := range outArgs { + if varName != variable.Name { + log.Error("variable name mismatch") + apiError.SendErrorResponse(g, http.StatusBadRequest, + "Variable name mismatch", + "POST /manual/continue", "") + return + } + } +} From e8c263b80ec253ddaf52688254bc873ea5aa1a0f Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:09:19 +0100 Subject: [PATCH 57/63] increase timeouts in testqueueexitontimeout so git tests dont fail maybe --- pkg/core/capability/manual/interaction/interaction_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index ff00ecdd..6054532b 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -52,7 +52,7 @@ func TestQueueFailWithoutTimeout(t *testing.T) { func TestQueueExitOnTimeout(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) - timeout := 30 * time.Millisecond + timeout := 50 * time.Millisecond testCtx, testCancel := context.WithTimeout(context.Background(), timeout) defer testCancel() @@ -70,7 +70,7 @@ func TestQueueExitOnTimeout(t *testing.T) { t.Fail() } - time.Sleep(50 * time.Millisecond) + time.Sleep(100 * time.Millisecond) expectedLogEntry := "manual command timed out. deregistering associated pending command" assert.NotEqual(t, len(hook.Entries), 0) From 97a95f3b9684d4a0fa081922e39a0d7140c942b9 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:12:42 +0100 Subject: [PATCH 58/63] loop on entries to check testqueueexitontimeout should fix test --- .../manual/interaction/interaction_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 6054532b..7d36eeaf 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -52,7 +52,7 @@ func TestQueueFailWithoutTimeout(t *testing.T) { func TestQueueExitOnTimeout(t *testing.T) { interaction := New([]IInteractionIntegrationNotifier{}) - timeout := 50 * time.Millisecond + timeout := 30 * time.Millisecond testCtx, testCancel := context.WithTimeout(context.Background(), timeout) defer testCancel() @@ -70,11 +70,18 @@ func TestQueueExitOnTimeout(t *testing.T) { t.Fail() } - time.Sleep(100 * time.Millisecond) + time.Sleep(50 * time.Millisecond) expectedLogEntry := "manual command timed out. deregistering associated pending command" assert.NotEqual(t, len(hook.Entries), 0) - assert.Equal(t, strings.Contains(hook.Entries[0].Message, expectedLogEntry), true) + contains := false + for _, entry := range hook.Entries { + if strings.Contains(entry.Message, expectedLogEntry) { + contains = true + break + } + } + assert.Equal(t, contains, true) } From 6b1038380690095a21df902d932baca70d01e881 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:51:02 +0100 Subject: [PATCH 59/63] fix test --- pkg/api/manual/manual_api.go | 58 +++++++++++-------- .../api/routes/manual_api/manual_api_test.go | 8 ++- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index eb33e96c..16bc3b48 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -168,34 +168,27 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { } // Check if variable names match - manualHandler.postContinueVariableNamesMatchCheck(outArgsUpdate.ResponseOutArgs, g) - - // Create object to pass to interaction capability - executionId, err := uuid.Parse(outArgsUpdate.ExecutionId) - if err != nil { - log.Error(err) + ok := manualHandler.postContinueVariableNamesMatchCheck(outArgsUpdate.ResponseOutArgs) + if !ok { + log.Error("variable name mismatch") apiError.SendErrorResponse(g, http.StatusBadRequest, - "Failed to parse execution ID", + "Variable name mismatch", "POST /manual/continue", "") return } - interactionResponse := manual.InteractionResponse{ - Metadata: execution.Metadata{ - StepId: outArgsUpdate.StepId, - ExecutionId: executionId, - PlaybookId: outArgsUpdate.PlaybookId, - }, - OutArgsVariables: outArgsUpdate.ResponseOutArgs, - ResponseStatus: outArgsUpdate.ResponseStatus, - ResponseError: nil, + 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 continue ID" + msg := "Failed to post the continue request" if errors.Is(err, manual.ErrorPendingCommandNotFound{}) { code = http.StatusNotFound msg = "Pending command not found" @@ -217,7 +210,6 @@ func (manualHandler *ManualHandler) PostContinue(g *gin.Context) { } // Utility - func (manualHandler *ManualHandler) parseCommandInfoToResponse(commandInfo manual.CommandInfo) api.InteractionCommandData { commandText := commandInfo.Context.Command.Command isBase64 := false @@ -241,14 +233,32 @@ func (manualHandler *ManualHandler) parseCommandInfoToResponse(commandInfo manua return response } -func (ManualHandler *ManualHandler) postContinueVariableNamesMatchCheck(outArgs cacao.Variables, g *gin.Context) { +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 { - log.Error("variable name mismatch") - apiError.SendErrorResponse(g, http.StatusBadRequest, - "Variable name mismatch", - "POST /manual/continue", "") - return + ok = false } } + return ok } diff --git a/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index 28295354..abbc8628 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -3,6 +3,7 @@ package manual_api_test import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" api_routes "soarca/pkg/api" @@ -154,7 +155,7 @@ func TestPostContinueCalled(t *testing.T) { mock_interaction_storage.AssertExpectations(t) } -func TestPostContinueFailsOnInvalidVariable(t *testing.T) { +func TestPostContinueFailsOnNonMatchingOutArg(t *testing.T) { mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) @@ -193,10 +194,11 @@ func TestPostContinueFailsOnInvalidVariable(t *testing.T) { 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(), "Variable name mismatch")) + assert.Equal(t, true, strings.Contains(recorder.Body.String(), expectedErr.Error())) - mock_interaction_storage.AssertExpectations(t) } From f08c59e82a442eb1b4cfcab846469aaa40dd4422 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:19:06 +0100 Subject: [PATCH 60/63] correct manual documentation and playbook example --- .../en/docs/core-components/modules.md | 123 +++++++++++++----- examples/manual-playbook.json | 89 +++++++++++++ .../api/routes/manual_api/manual_api_test.go | 2 +- 3 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 examples/manual-playbook.json diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 63e66b10..3fd01857 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -332,18 +332,18 @@ The manual step should provide a timeout. SOARCA will by default use a timeout o #### 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 expected to be filled. +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. The variables are streamed inside the playbook execution and handled accordingly. +3. Once something replies, the variables are streamed inside the playbook execution and handled accordingly. -Because the *somewhere* and *somehow* for posting a message can vary, and the *something* that replies can vary too, SOARCA adopts a flexible architecture to accomodate different ways of manual *interactions*. Below a view of the architecture. +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 manual command will queue the instruction into the *CapabilityInteraction* module. The module does essentially three things: -1. it stores the status of the manual command, and handles the SOARCA API interactions with the manual command. +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 *ManualCommand* then completes its execution, having eventually updated the values for the variables in the outArgs of the command. Timeouts or errors are handled opportunely. +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 @@ -368,11 +368,11 @@ interface ICapabilityInteraction{ interface IInteracionStorage{ GetPendingCommands() []CommandData GetPendingCommand(execution.metadata) CommandData - PostContinue(execution.metadata) StatusCode + PostContinue(execution.metadata) ExecutionInfo } interface IInteractionIntegrationNotifier { - Notify(command InteractionIntegrationCommand, channel chan InteractionIntegrationResponse) + Notify(command InteractionIntegrationCommand, channel manualCapabilityCommunication.Channel) } class Interaction { @@ -407,40 +407,103 @@ control "ManualCommand" as manual control "Interaction" as interaction control "ManualAPI" as api control "ThirdPartyManualIntegration" as 3ptool +participant "Integration" as integration - -manual -> interaction : Queue(command, capabilityChannel) +-> manual : ...manual command +manual -> interaction : Queue(command, capabilityChannel, timeoutContext) manual -> manual : idle wait on capabilityChannel -activate interaction -interaction -> interaction : save manual command status -alt Third Party Integration flow -interaction ->> 3ptool : async Notify(interactionCommand, integrationChannel) -activate 3ptool -interaction ->> interaction : async wait on integrationChannel - -3ptool <--> Integration : command posting and handling -3ptool -> 3ptool : post InteractionIntegrationResponse on channel -3ptool --> interaction : integrationChannel <- InteractionIntegrationResponse -interaction --> manual : capabilityChannel <- InteractionResponse -deactivate 3ptool -else Native ManualAPI flow -interaction ->> interaction : async wait on integrationChannel -api -> interaction : GetPendingCommands() -api -> interaction : GetPendingCommand(execution.metadata) -api -> interaction : PostContinue(InteractionResponse) -interaction --> manual : capabilityChannel <- InteractionResponse -end +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 + manual -->> 3ptool : timeoutContext.Deadline() event + 3ptool --> integration : custom handling command timed-out view + interaction -> interaction : de-register pending command + deactivate interaction + deactivate manual + end +else + group Third Party Integration flow + activate manual + activate integration + integration --> 3ptool : custom handling command response + 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 + deactivate 3ptool + deactivate manual + 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/test/integration/api/routes/manual_api/manual_api_test.go b/test/integration/api/routes/manual_api/manual_api_test.go index abbc8628..16f8b55c 100644 --- a/test/integration/api/routes/manual_api/manual_api_test.go +++ b/test/integration/api/routes/manual_api/manual_api_test.go @@ -155,7 +155,7 @@ func TestPostContinueCalled(t *testing.T) { mock_interaction_storage.AssertExpectations(t) } -func TestPostContinueFailsOnNonMatchingOutArg(t *testing.T) { +func TestPostContinueFailsOnNonMatchingOutArgNames(t *testing.T) { mock_interaction_storage := mock_interaction_storage.MockInteractionStorage{} manualApiHandler := manual_api.NewManualHandler(&mock_interaction_storage) From a66631196a3a252e7303f4f073d83e0a72afd65c Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:38:09 +0100 Subject: [PATCH 61/63] improve manual command flow diagram --- .../en/docs/core-components/modules.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/content/en/docs/core-components/modules.md b/docs/content/en/docs/core-components/modules.md index 3fd01857..a6af5818 100644 --- a/docs/content/en/docs/core-components/modules.md +++ b/docs/content/en/docs/core-components/modules.md @@ -417,7 +417,7 @@ activate manual activate interaction interaction -> interaction : save pending manual command interaction ->> 3ptool : Notify(command, capabilityChannel, timeoutContext) -3ptool --> integration : custom handling command posting +3ptool <--> integration : custom handling command posting deactivate interaction alt Command Response @@ -432,17 +432,21 @@ alt Command Response deactivate api interaction --> manual : capabilityChannel <- InteractionResponse manual ->> interaction : timeoutContext.Cancel() event - manual -->> 3ptool : timeoutContext.Deadline() event - 3ptool --> integration : custom handling command timed-out view 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 - integration --> 3ptool : custom handling command response deactivate integration activate 3ptool 3ptool -> 3ptool : build InteractionResponse @@ -452,8 +456,15 @@ else 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 @@ -485,8 +496,8 @@ deactivate interaction group Command execution times out manual -> manual : timeoutContext.Deadline() - manual -->> interaction : timeoutContext.Deadline() event - manual -->> 3ptool : timeoutContext.Deadline() event + 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 From fdb7228475d400b39780f803964643f826b27f23 Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 21 Jan 2025 15:58:03 +0100 Subject: [PATCH 62/63] add timeout in test for sync in async test execution --- .../manual/interaction/interaction_test.go | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index 7d36eeaf..f5411d8a 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -10,6 +10,7 @@ import ( "soarca/pkg/models/cacao" "soarca/pkg/models/execution" manualModel "soarca/pkg/models/manual" + "sort" "strings" "testing" "time" @@ -154,13 +155,14 @@ func TestGetAllPendingInteractions(t *testing.T) { testChan := make(chan manualModel.InteractionResponse) defer close(testChan) - err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + localTestInteractionCommand := testInteractionCommand + err := interaction.registerPendingInteraction(localTestInteractionCommand, testChan) if err != nil { t.Log(err) t.Fail() } - testNewInteractionCommand := testInteractionCommand + testNewInteractionCommand := localTestInteractionCommand newExecId := "50b6d52c-6efc-4516-a242-dfbc5c89d421" testNewInteractionCommand.Metadata.ExecutionId = uuid.MustParse(newExecId) @@ -170,17 +172,25 @@ func TestGetAllPendingInteractions(t *testing.T) { t.Fail() } - expectedInteractions := []manualModel.CommandInfo{testInteractionCommand, testNewInteractionCommand} - + 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() } - fmt.Println("received interactions") - fmt.Println(string(receivedInteractionsJson)) + t.Log("received interactions") + t.Log(string(receivedInteractionsJson)) for i, receivedInteraction := range receivedInteractions { if expectedInteractions[i].Metadata != receivedInteraction.Metadata { @@ -206,17 +216,19 @@ func TestRegisterRetrieveSameExecutionMultiplePendingInteraction(t *testing.T) { testChan := make(chan manualModel.InteractionResponse) defer close(testChan) - err := interaction.registerPendingInteraction(testInteractionCommand, testChan) + localTestInteractionCommand := testInteractionCommand + + err := interaction.registerPendingInteraction(localTestInteractionCommand, testChan) if err != nil { t.Log(err) t.Fail() } - testNewInteractionCommandSecond := testInteractionCommand + testNewInteractionCommandSecond := localTestInteractionCommand newStepId2 := "test_second_step_id" testNewInteractionCommandSecond.Metadata.StepId = newStepId2 - testNewInteractionCommandThird := testInteractionCommand + testNewInteractionCommandThird := localTestInteractionCommand newStepId3 := "test_third_step_id" testNewInteractionCommandThird.Metadata.StepId = newStepId3 @@ -318,7 +330,7 @@ func TestPostContinueWarningsRaised(t *testing.T) { 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 From 1b5c2ea69d851a44f0c55c2d012a70994af4158b Mon Sep 17 00:00:00 2001 From: lucamrgs <39555424+lucamrgs@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:00:30 +0100 Subject: [PATCH 63/63] reduce time sleep values in tests --- pkg/core/capability/manual/interaction/interaction_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/core/capability/manual/interaction/interaction_test.go b/pkg/core/capability/manual/interaction/interaction_test.go index f5411d8a..2e286af1 100644 --- a/pkg/core/capability/manual/interaction/interaction_test.go +++ b/pkg/core/capability/manual/interaction/interaction_test.go @@ -336,7 +336,7 @@ func TestPostContinueWarningsRaised(t *testing.T) { // Simulating Manual Capability closing the channel and the context close(testCapComms.Channel) testCancel() - time.Sleep(800 * time.Millisecond) + 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."