diff --git a/.env.example b/.env.example index 83a5bc2f3..c69ca0fee 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,8 @@ AUTH_ENABLED: false OIDC_PROVIDER: "https://authentikuri:9443/application/o/soarca/" OIDC_CLIENT_ID: "some client ID" OIDC_SKIP_TLS_VERIFY: false + +# SSH Key Management +ENABLE_SSH_KMS: false +SSH_KMS_DIR: "deployments/docker/testing/ssh-kms-test/ssh-keystore/" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d29f5e63..7169bf41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,10 +59,16 @@ jobs: - name: Install swaggo run: go install github.com/swaggo/swag/cmd/swag@latest timeout-minutes: 12 - - name: Start docker containers for test + - name: Start docker container for http test run: docker compose -f "deployments/docker/testing/httpbin-test/docker-compose.yml" up -d --build + - name: Start docker container for ssh test + run: docker compose -f "deployments/docker/testing/ssh-test/docker-compose.yml" up -d --build + - name: Start docker container for kms test + run: | + docker compose -f "deployments/docker/testing/ssh-kms-test/docker-compose.yml" up -d --build + docker compose -f "deployments/docker/testing/ssh-kms-test/docker-compose.yml" cp ssh_kms_server:/test deployments/docker/testing/ssh-kms-test + docker compose -f "deployments/docker/testing/ssh-kms-test/docker-compose.yml" cp ssh_kms_server:/test.pub deployments/docker/testing/ssh-kms-test - name: Run tests run: | - docker compose -f "deployments/docker/testing/ssh-test/docker-compose.yml" up -d --build make ci-test diff --git a/.gitignore b/.gitignore index 387a33678..4be741be4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ bin/* api/* **.env test/cacao/flatfile-db-example.json +pkg/core/capability/ssh/.ssh_test/ docs/public diff --git a/cmd/soarca/main.go b/cmd/soarca/main.go index fbb248131..61dfa759a 100644 --- a/cmd/soarca/main.go +++ b/cmd/soarca/main.go @@ -45,7 +45,7 @@ func main() { err := godotenv.Load(".env") if err != nil { - log.Warning("Failed to read env variable, but will continue") + log.Warning("Failed to read env variable, but will continue. Error: ", err) } Host = "localhost:" + utils.GetEnv("PORT", "8080") api.SwaggerInfo.Host = Host diff --git a/deployments/docker/testing/ssh-kms-test/Dockerfile b/deployments/docker/testing/ssh-kms-test/Dockerfile new file mode 100644 index 000000000..ea3e6b6c2 --- /dev/null +++ b/deployments/docker/testing/ssh-kms-test/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:latest + +RUN apt update && apt install openssh-server sudo -y + +RUN sudo useradd -m sshtest + +RUN ssh-keygen -q -N "" -f /test + +RUN mkdir -p /home/sshtest/.ssh/ + +RUN cat /test.pub > /home/sshtest/.ssh/authorized_keys + +RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config + +RUN echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config + +RUN sudo service ssh start + +EXPOSE 22 + +CMD ["sudo","/usr/sbin/sshd","-D"] diff --git a/deployments/docker/testing/ssh-kms-test/docker-compose.yml b/deployments/docker/testing/ssh-kms-test/docker-compose.yml new file mode 100644 index 000000000..43ff56745 --- /dev/null +++ b/deployments/docker/testing/ssh-kms-test/docker-compose.yml @@ -0,0 +1,8 @@ + services: + ssh_kms_server: + container_name: ssh_kms_server + build: + dockerfile: Dockerfile + ports: + - 2223:22 + diff --git a/examples/ssh-kms-playbook.json b/examples/ssh-kms-playbook.json new file mode 100644 index 000000000..8a2a5b4b3 --- /dev/null +++ b/examples/ssh-kms-playbook.json @@ -0,0 +1,117 @@ +{ + "type": "playbook", + "spec_version": "cacao-2.0", + "id": "playbook--300270f9-0e64-42c8-93cc-0927edbe3ae7", + "name": "Example ssh", + "description": "This playbook demonstrates ssh functionality", + "playbook_types": [ + "notification" + ], + "created_by": "identity--96abab60-238a-44ff-8962-5806aa60cbce", + "created": "2023-11-20T15:56:00.123456Z", + "modified": "2023-11-20T15:56:00.123456Z", + "valid_from": "2023-11-20T15:56:00.123456Z", + "valid_until": "2123-11-20T15:56:00.123456Z", + "priority": 1, + "severity": 1, + "impact": 1, + "labels": [ + "soarca", + "ssh", + "example" + ], + "authentication_info_definitions": { + "user-auth--b7ddc2ea-9f6a-4e82-8eaa-be202e942090": { + "type": "user-auth", + "kms":true, + "kms_key_identifier": "test", + "username": "linuxserver.io" + } + }, + "agent_definitions": { + "soarca--00010001-1000-1000-a000-000100010001": { + "type": "soarca", + "name": "soarca-ssh" + } + }, + "target_definitions": { + "ssh--1c3900b4-f86b-430d-b415-12312b9e31f4": { + "type": "ssh", + "name": "system 1", + "address": { + "ipv4": [ + "127.0.0.1" + ] + }, + "port": "2223", + "authentication_info": "user-auth--b7ddc2ea-9f6a-4e82-8eaa-be202e942090" + } + }, + "external_references": [ + { + "name": "TNO COSSAS", + "description": "TNO COSSAS", + "source": "TNO COSSAS", + "url": "https://cossas-project.org" + } + ], + "workflow_start": "start--9e7d62b2-88ac-4656-94e1-dbd4413ba008", + "workflow_exception": "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958", + "workflow": { + "start--9e7d62b2-88ac-4656-94e1-dbd4413ba008": { + "type": "start", + "name": "Start ssh example", + "on_completion": "action--eb9372d4-d524-49fc-bf24-be26ea084779" + }, + "action--eb9372d4-d524-49fc-bf24-be26ea084779": { + "type": "action", + "name": "Execute command", + "description": "Execute command specified in variable", + "on_completion": "action--88f4c4df-fa96-44e6-b310-1c06d193ea55", + "commands": [ + { + "type": "ssh", + "command": "__command__:value" + } + ], + "targets": [ + "ssh--1c3900b4-f86b-430d-b415-12312b9e31f4" + ], + "agent": "soarca--00010001-1000-1000-a000-000100010001", + "step_variables": { + "__command__": { + "type": "string", + "value": "ls -la", + "constant": true + } + } + }, + "action--88f4c4df-fa96-44e6-b310-1c06d193ea55": { + "type": "action", + "name": "Touch file", + "description": "Touch file at path specified by variable", + "on_completion": "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958", + "commands": [ + { + "type": "ssh", + "command": "touch __path__:value" + } + ], + "targets": [ + "ssh--1c3900b4-f86b-430d-b415-12312b9e31f4" + ], + "agent": "soarca--00010001-1000-1000-a000-000100010001", + "step_variables": { + "__path__": { + "type": "string", + "value": "touchy", + "constant": true + } + } + }, + "end--a6f0b81e-affb-4bca-b4f6-a2d5af908958": { + "type": "end", + "name": "End Flow" + } + } +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 1d95dd4e4..ea8a0c2f7 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + keymanagementrepository "soarca/internal/database/keymanagement" "soarca/internal/database/memory" "soarca/internal/logger" @@ -20,6 +21,7 @@ import ( "soarca/pkg/core/executors/action" "soarca/pkg/core/executors/condition" "soarca/pkg/core/executors/playbook_action" + "soarca/pkg/keymanagement" "soarca/pkg/reporting/cases" "soarca/pkg/reporting/reporter" "soarca/pkg/utils" @@ -60,8 +62,10 @@ func init() { } type Controller struct { - finController finChannelController.IFinController - playbookRepo playbookrepository.IPlaybookRepository + finController finChannelController.IFinController + playbookRepo playbookrepository.IPlaybookRepository + keyManagementRepo keymanagementrepository.IKeyManagementRepository + keyManagement *keymanagement.KeyManagement } var mainController = Controller{} @@ -74,7 +78,7 @@ const defaultCacheSize int = 10 var mainInteraction = interaction.New(registerManualIntegration()) func (controller *Controller) NewDecomposer() decomposer.IDecomposer { - ssh := new(ssh.SshCapability) + ssh := &ssh.SshCapability{Keys: controller.keyManagement} capabilities := map[string]capability.ICapability{ssh.GetType(): ssh} skip, _ := strconv.ParseBool(utils.GetEnv("HTTP_SKIP_CERT_VALIDATION", "false")) @@ -141,6 +145,7 @@ func (controller *Controller) setupDatabase() error { initMongoDatabase, _ := strconv.ParseBool(utils.GetEnv("DATABASE", "false")) if initMongoDatabase { + log.Info("Setting up mongo database") mongo.LoadComponent() @@ -158,14 +163,22 @@ func (controller *Controller) setupDatabase() error { return err } controller.playbookRepo = playbookrepository.SetupPlaybookRepository(mongo.GetCacaoRepo(), mongo.DefaultLimitOpts()) + controller.keyManagementRepo = keymanagementrepository.SetupKeyManagementRepository(mongo.GetKeyManagementRepo(), mongo.DefaultLimitOpts()) } else { // Use in memory database - controller.playbookRepo = memory.New() + log.Info("Setting up in-memory database") + controller.playbookRepo = memory.NewPlaybookDatabase() + controller.keyManagementRepo = memory.NewKeyManagementDatabase() } return nil } +func (controller *Controller) setupKeyManagement() error { + controller.keyManagement = keymanagement.InitKeyManagement(controller.keyManagementRepo) + return nil +} + func (controller *Controller) GetDatabaseInstance() playbookrepository.IPlaybookRepository { return controller.playbookRepo } @@ -249,6 +262,12 @@ func initializeCore(app *gin.Engine) error { return err } + err = mainController.setupKeyManagement() + if err != nil { + log.Error("Failed to setup key management:", err) + return err + } + err = routes.Api(app, &mainController, &mainController) if err != nil { log.Error(err) @@ -269,6 +288,8 @@ func initializeCore(app *gin.Engine) error { return err } + routes.KeyManagement(app, mainController.keyManagement) + // Manual capability native routes routes.Manual(app, mainInteraction) diff --git a/internal/database/keymanagement/keymanagement.go b/internal/database/keymanagement/keymanagement.go new file mode 100644 index 000000000..a3f0be88c --- /dev/null +++ b/internal/database/keymanagement/keymanagement.go @@ -0,0 +1,69 @@ +package keymanagementrepository + +import ( + "errors" + "soarca/internal/database" + "soarca/pkg/models/keymanagement" +) + +type IKeyManagementRepository interface { + GetKeyNames() ([]string, error) + Create(name string, keypair keymanagement.KeyPair) error + Read(id string) (keymanagement.KeyPair, error) + Update(id string, keypair keymanagement.KeyPair) error + Delete(id string) error +} + +type KeyManagementRepository struct { + db database.Database + options database.FindOptions +} + +type KeyPairEntry struct { + name string + keypair keymanagement.KeyPair +} + +func SetupKeyManagementRepository(db database.Database, options database.FindOptions) *KeyManagementRepository { + return &KeyManagementRepository{db: db, options: options} +} + +func (keymanagementRepo *KeyManagementRepository) GetKeyNames() ([]string, error) { + keys, err := keymanagementRepo.db.Find(nil) + if err != nil { + return nil, err + } + ret := []string{} + for _, key := range keys { + ret = append(ret, key.(KeyPairEntry).name) + } + return ret, nil +} + +func (keymanagementRepo *KeyManagementRepository) Create(name string, keypair keymanagement.KeyPair) error { + return keymanagementRepo.db.Create(KeyPairEntry{name, keypair}) +} + +func (keymanagementRepo *KeyManagementRepository) Read(id string) (keymanagement.KeyPair, error) { + returnedObject, err := keymanagementRepo.db.Read(id) + if err != nil { + return keymanagement.KeyPair{}, err + } + + keypair, ok := returnedObject.(keymanagement.KeyPair) + + if !ok { + err = errors.New("could not cast lookup object to keypair type") + return keymanagement.KeyPair{}, err + } + + return keypair, nil +} + +func (keymanagementRepo *KeyManagementRepository) Update(id string, keypair keymanagement.KeyPair) error { + return keymanagementRepo.db.Update(id, keypair) +} + +func (keymanagementRepo *KeyManagementRepository) Delete(id string) error { + return keymanagementRepo.db.Delete(id) +} diff --git a/internal/database/memory/keymanagement.go b/internal/database/memory/keymanagement.go new file mode 100644 index 000000000..bdfeb0989 --- /dev/null +++ b/internal/database/memory/keymanagement.go @@ -0,0 +1,41 @@ +package memory + +import ( + "fmt" + "soarca/pkg/models/keymanagement" +) + +type InMemoryKeyManagementDatabase struct { + keys map[string]keymanagement.KeyPair +} + +func NewKeyManagementDatabase() *InMemoryKeyManagementDatabase { + return &InMemoryKeyManagementDatabase{keys: make(map[string]keymanagement.KeyPair)} +} + +func (database *InMemoryKeyManagementDatabase) GetKeyNames() ([]string, error) { + ret := []string{} + for key := range database.keys { + ret = append(ret, key) + } + return ret, nil +} +func (database *InMemoryKeyManagementDatabase) Create(name string, keypair keymanagement.KeyPair) error { + database.keys[name] = keypair + return nil +} +func (database *InMemoryKeyManagementDatabase) Read(id string) (keymanagement.KeyPair, error) { + keypair, ok := database.keys[id] + if !ok { + return keymanagement.KeyPair{}, fmt.Errorf("could not find key named %s", id) + } + return keypair, nil +} +func (database *InMemoryKeyManagementDatabase) Update(id string, keypair keymanagement.KeyPair) error { + database.keys[id] = keypair + return nil +} +func (database *InMemoryKeyManagementDatabase) Delete(id string) error { + delete(database.keys, id) + return nil +} diff --git a/internal/database/memory/memory.go b/internal/database/memory/playbook.go similarity index 70% rename from internal/database/memory/memory.go rename to internal/database/memory/playbook.go index ef0bddc8e..ad94591fd 100644 --- a/internal/database/memory/memory.go +++ b/internal/database/memory/playbook.go @@ -7,15 +7,15 @@ import ( "soarca/pkg/models/decoder" ) -type InMemoryDatabase struct { +type InMemoryPlaybookDatabase struct { playbooks map[string]cacao.Playbook } -func New() *InMemoryDatabase { - return &InMemoryDatabase{playbooks: make(map[string]cacao.Playbook)} +func NewPlaybookDatabase() *InMemoryPlaybookDatabase { + return &InMemoryPlaybookDatabase{playbooks: make(map[string]cacao.Playbook)} } -func (memory *InMemoryDatabase) GetPlaybooks() ([]cacao.Playbook, error) { +func (memory *InMemoryPlaybookDatabase) GetPlaybooks() ([]cacao.Playbook, error) { size := len(memory.playbooks) playbookList := make([]cacao.Playbook, 0, size) for _, playbook := range memory.playbooks { @@ -25,7 +25,7 @@ func (memory *InMemoryDatabase) GetPlaybooks() ([]cacao.Playbook, error) { return playbookList, nil } -func (memory *InMemoryDatabase) GetPlaybookMetas() ([]api.PlaybookMeta, error) { +func (memory *InMemoryPlaybookDatabase) GetPlaybookMetas() ([]api.PlaybookMeta, error) { size := len(memory.playbooks) playbookList := make([]api.PlaybookMeta, 0, size) for _, playbook := range memory.playbooks { @@ -40,7 +40,7 @@ func (memory *InMemoryDatabase) GetPlaybookMetas() ([]api.PlaybookMeta, error) { return playbookList, nil } -func (memory *InMemoryDatabase) Create(json *[]byte) (cacao.Playbook, error) { +func (memory *InMemoryPlaybookDatabase) Create(json *[]byte) (cacao.Playbook, error) { if json == nil { return cacao.Playbook{}, errors.New("empty input") @@ -57,7 +57,7 @@ func (memory *InMemoryDatabase) Create(json *[]byte) (cacao.Playbook, error) { return memory.playbooks[result.ID], nil } -func (memory *InMemoryDatabase) Read(id string) (cacao.Playbook, error) { +func (memory *InMemoryPlaybookDatabase) Read(id string) (cacao.Playbook, error) { playbook, ok := memory.playbooks[id] if !ok { return cacao.Playbook{}, errors.New("playbook is not in repository") @@ -65,7 +65,7 @@ func (memory *InMemoryDatabase) Read(id string) (cacao.Playbook, error) { return playbook, nil } -func (memory *InMemoryDatabase) Update(id string, json *[]byte) (cacao.Playbook, error) { +func (memory *InMemoryPlaybookDatabase) Update(id string, json *[]byte) (cacao.Playbook, error) { playbook, err := memory.Read(id) if err != nil { return playbook, err @@ -78,7 +78,7 @@ func (memory *InMemoryDatabase) Update(id string, json *[]byte) (cacao.Playbook, return *updatePlaybook, nil } -func (memory *InMemoryDatabase) Delete(id string) error { +func (memory *InMemoryPlaybookDatabase) Delete(id string) error { delete(memory.playbooks, id) return nil } diff --git a/internal/database/memory/memory_test.go b/internal/database/memory/playbook_test.go similarity index 96% rename from internal/database/memory/memory_test.go rename to internal/database/memory/playbook_test.go index d206aae52..a7a5408b6 100644 --- a/internal/database/memory/memory_test.go +++ b/internal/database/memory/playbook_test.go @@ -32,7 +32,7 @@ func TestCreate(t *testing.T) { } var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() playbook, err := mem.Create(&byteValue) assert.Equal(t, err, nil) assert.Equal(t, playbook, workflow) @@ -56,7 +56,7 @@ func TestRead(t *testing.T) { var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() empty, err := mem.Read(workflow.ID) assert.Equal(t, err, errors.New("playbook is not in repository")) assert.Equal(t, empty, cacao.Playbook{}) @@ -87,7 +87,7 @@ func TestUpdate(t *testing.T) { var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() empty, err := mem.Update(workflow.ID, nil) assert.Equal(t, err, errors.New("playbook is not in repository")) assert.Equal(t, empty, cacao.Playbook{}) @@ -129,7 +129,7 @@ func TestDelete(t *testing.T) { var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() err = mem.Delete(workflow.ID) assert.Equal(t, err, nil) @@ -166,7 +166,7 @@ func TestGetAllPlaybooks(t *testing.T) { var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() list := []string{ "playbook--f47d4081-21ed-4f21-9d05-6b368d73da30", @@ -214,7 +214,7 @@ func TestGetAllPlaybookMetas(t *testing.T) { var workflow = decoder.DecodeValidate(byteValue) - mem := New() + mem := NewPlaybookDatabase() list := []string{ "playbook--f47d4081-21ed-4f21-9d05-6b368d73da30", diff --git a/internal/database/mongodb/mongo.go b/internal/database/mongodb/mongo.go index 15964997f..06764243c 100644 --- a/internal/database/mongodb/mongo.go +++ b/internal/database/mongodb/mongo.go @@ -6,6 +6,7 @@ import ( "reflect" "time" + keymanagementrepository "soarca/internal/database/keymanagement" "soarca/internal/database/projections" cacao "soarca/pkg/models/cacao" @@ -18,11 +19,12 @@ const writeErrorDuplicationCode = 11000 var ( cacaoPlayBookRepo *mongoCollection[cacao.Playbook] + keyManagementRepo *mongoCollection[keymanagementrepository.KeyPairEntry] mongoclient *mongo.Client ) type dbtypes interface { - cacao.Playbook // | for other supported types + cacao.Playbook | keymanagementrepository.KeyPairEntry // | for other supported types } type mongoCollection[T dbtypes] struct { @@ -57,6 +59,9 @@ func (mongoOpts mongoFindOptions) GetProjectionByType(interface{}) interface{} { func GetCacaoRepo() *mongoCollection[cacao.Playbook] { return cacaoPlayBookRepo } +func GetKeyManagementRepo() *mongoCollection[keymanagementrepository.KeyPairEntry] { + return keyManagementRepo +} // func GetMongoClient() *mongodbClient { // return mongoclient @@ -77,7 +82,16 @@ func SetupMongodb(uri string, username string, password string) error { } cacaoPlayBookRepo, err = NewMongoCollection[cacao.Playbook](mongoclient, "soarca", "cacoa_playbook_collection") - return err + if err != nil { + log.Error("failed to setup playbook MongoCollection, error msg: ", err.Error()) + return err + } + keyManagementRepo, err = NewMongoCollection[keymanagementrepository.KeyPairEntry](mongoclient, "keymanagement", "keymanagement_collection") + if err != nil { + log.Error("failed to setup kms MongoCollection, error msg: ", err.Error()) + return err + } + return nil } // helper function to poperly obtain whether object is already in the database store @@ -220,7 +234,7 @@ func NewMongoCollection[T dbtypes](mongo *mongo.Client, dbName string, colName s } func InitMongoClient(mongo_uri string, username string, password string) error { - log.Trace("Trying to setup new MongoClient") + log.Trace("Trying to setup new MongoClient at uri ", mongo_uri) var err error if mongo_uri == "" { log.Error("mongo uri not valid, because empty") @@ -232,6 +246,7 @@ func InitMongoClient(mongo_uri string, username string, password string) error { return errors.New("username or password not correctly set") } + log.Trace("Logging in to mongo with username ", username, " and password ", password) clientOpts := options.Client().ApplyURI(mongo_uri).SetAuth(options.Credential{ Username: username, Password: password, diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4b4544b94..87963cc21 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -31,7 +31,7 @@ const ( Trace ) -func (severity Severity) fromString(name string) Severity { +func severityFromString(name string) Severity { nameToLower := strings.ToLower(name) switch nameToLower { case "panic": @@ -106,7 +106,12 @@ func Logger(name string, severity Severity, fileName FileName, format Format) *L setFormat(instance, globalLogFormat) - instance.SetLevel(logrus.Level(severity.fromString(globalLogSeverity))) + globalSeverityLevel := severityFromString(globalLogSeverity) + if globalSeverityLevel > severity { + instance.SetLevel(logrus.Level(globalSeverityLevel)) + } else { + instance.SetLevel(logrus.Level(severity)) + } if globalOperationMode == development { if fileName != "" { diff --git a/pkg/api/api.go b/pkg/api/api.go index f1dc96982..042346f3e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -7,10 +7,12 @@ import ( "soarca/internal/controller/decomposer_controller" "soarca/internal/controller/informer" "soarca/internal/logger" + keymanagement_handler "soarca/pkg/api/keymanagement" playbook_handler "soarca/pkg/api/playbook" reporter_handler "soarca/pkg/api/reporter" status_handler "soarca/pkg/api/status" "soarca/pkg/core/capability/manual/interaction" + "soarca/pkg/keymanagement" manual_handler "soarca/pkg/api/manual" @@ -53,6 +55,11 @@ func Manual(app *gin.Engine, interaction interaction.IInteractionStorage) { manualHandler := manual_handler.NewManualHandler(interaction) ManualRoutes(app, manualHandler) } +func KeyManagement(app *gin.Engine, key_manager *keymanagement.KeyManagement) { + log.Trace("Setting up key management routes") + keyManagement := keymanagement_handler.NewKeyManagementHandler(key_manager) + KeyManagementRoutes(app, keyManagement) +} func Api(app *gin.Engine, controller decomposer_controller.IController, @@ -144,3 +151,13 @@ func ManualRoutes(route *gin.Engine, manualHandler *manual_handler.ManualHandler manualRoutes.POST("/continue", manualHandler.PostContinue) } } + +func KeyManagementRoutes(route *gin.Engine, keyManagementHandler *keymanagement_handler.KeyManagementHandler) { + keyManagementRoutes := route.Group("/keymanagement") + { + keyManagementRoutes.GET("/", keyManagementHandler.GetKeys) + keyManagementRoutes.PUT("/:keyname", keyManagementHandler.AddKey) + keyManagementRoutes.PATCH("/:keyname", keyManagementHandler.UpdateKey) + keyManagementRoutes.DELETE("/:keyname", keyManagementHandler.RevokeKey) + } +} diff --git a/pkg/api/keymanagement/keymanagement_api.go b/pkg/api/keymanagement/keymanagement_api.go new file mode 100644 index 000000000..cdcd133e6 --- /dev/null +++ b/pkg/api/keymanagement/keymanagement_api.go @@ -0,0 +1,143 @@ +package keymanagement_api + +import ( + "encoding/json" + "io" + "net/http" + "reflect" + "soarca/internal/logger" + apiError "soarca/pkg/api/error" + "soarca/pkg/keymanagement" + "soarca/pkg/models/api" + + "github.com/gin-gonic/gin" +) + +var log *logger.Log + +type Empty struct{} + +func init() { + log = logger.Logger(reflect.TypeOf(Empty{}).PkgPath(), logger.Trace, "", logger.Json) +} + +type KeyManagementHandler struct { + Manager *keymanagement.KeyManagement +} + +func NewKeyManagementHandler(manager *keymanagement.KeyManagement) *KeyManagementHandler { + return &KeyManagementHandler{ + Manager: manager, + } +} + +// GetKeys GET handler for obtaining all keys in the key managements system +// +// @Summary gets all keys from the KMS +// @Schemes +// @Description return all keys in the KMS +// @Tags keymanagement +// @Produce json +// @success 200 {array} string +// @failure 400 {object} api.Error +// @Router /keymanagement/ [GET] +func (handler *KeyManagementHandler) GetKeys(context *gin.Context) { + keyInfo, err := handler.Manager.ListAllNames() + if err != nil { + log.Error("Listing keys has failed: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to read keys on server side", "GET /keymanagement/", "") + return + } + log.Trace("Listing all key names") + context.JSON(http.StatusOK, keyInfo) +} + +// AddKey PUT handler to add key to KMS +// +// @Summary add key to KMS +// @Schemes +// @Description adds a key to the KMS; load key into cache and write to database +// @Tags keymanagement +// @Param data body api.KeyManagementKey true "key" +// @Produce json +// @success 200 {json} Empty +// @failure 400 {object} api.Error +// @Router /keymanagement/:keyname/ [PUT] +func (handler *KeyManagementHandler) AddKey(context *gin.Context) { + keyname := context.Param("keyname") + jsonData, err := io.ReadAll(context.Request.Body) + if err != nil { + log.Error("Submit key has failed: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to read json on server side", "PUT /keymanagement/:keyname", "") + return + } + var key api.KeyManagementKey + if err := json.Unmarshal(jsonData, &key); err != nil { + log.Error("Submit key failed to unmarshal: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to marshall json on server side", "PUT /keymanagement/:keyname", "") + return + } + if err := handler.Manager.Insert(key.Public, key.Private, key.Passphrase, keyname); err != nil { + log.Error("Submit key failed to insert: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to insert key on server side", "PUT /keymanagement/:keyname", "") + return + + } + log.Trace("Inserted key ", keyname) + context.JSON(http.StatusOK, Empty{}) +} + +// UpdateKey PATCH handler to update key in KMS +// +// @Summary update key in KMS +// @Schemes +// @Description update a key in the KMS; load key into cache and write to database +// @Tags keymanagement +// @Param data body api.KeyManagementKey true "key" +// @Produce json +// @success 200 {json} Empty +// @failure 400 {object} api.Error +// @Router /keymanagement/:keyname/ [PATCH] +func (handler *KeyManagementHandler) UpdateKey(context *gin.Context) { + keyname := context.Param("keyname") + jsonData, err := io.ReadAll(context.Request.Body) + if err != nil { + log.Error("Update key has failed: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to read json on server side", "PATCH /keymanagement/:keyname", "") + return + } + var key api.KeyManagementKey + if err := json.Unmarshal(jsonData, &key); err != nil { + log.Error("Update key failed to unmarshal: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to marshall json on server side", "PATCH /keymanagement/:keyname", "") + return + } + if err := handler.Manager.Update(key.Public, key.Private, key.Passphrase, keyname); err != nil { + log.Error("Update key failed to insert: ", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to update key on server side", "PATCH /keymanagement/:keyname", "") + return + } + log.Trace("Updated key ", keyname) + context.JSON(http.StatusOK, Empty{}) +} + +// RevokeKey DELETE handler to remove key from KMS +// +// @Summary remove key from KMS +// @Schemes +// @Description revokes the key by moving it to .revoked and renaming it +// @Tags keymanagement +// @Produce json +// @success 200 {json} Empty +// @failure 400 {object} api.Error +// @Router /keymanagement/:keyname/ [DELETE] +func (handler *KeyManagementHandler) RevokeKey(context *gin.Context) { + keyname := context.Param("keyname") + if err := handler.Manager.Revoke(keyname); err != nil { + log.Error("Failed to remove key:", err.Error()) + apiError.SendErrorResponse(context, http.StatusBadRequest, "Failed to delete key on server side", "DELETE /keymanagement/:keyname", "") + return + } + context.JSON(http.StatusOK, Empty{}) + log.Trace("Removed key ", keyname) +} diff --git a/pkg/api/manual/manual_api.go b/pkg/api/manual/manual_api.go index 74ea74d21..ff4bb918a 100644 --- a/pkg/api/manual/manual_api.go +++ b/pkg/api/manual/manual_api.go @@ -58,7 +58,7 @@ func NewManualHandler(interaction interaction.IInteractionStorage) *ManualHandle // @Tags manual // @Accept json // @Produce json -// @Success 200 {object} api.Execution +// @Success 200 {object} []api.InteractionCommandData // @failure 400 {object} []api.InteractionCommandData // @Router /manual/ [GET] func (manualHandler *ManualHandler) GetPendingCommands(g *gin.Context) { diff --git a/pkg/core/capability/ssh/ssh.go b/pkg/core/capability/ssh/ssh.go index 25f3498ae..525a235b6 100644 --- a/pkg/core/capability/ssh/ssh.go +++ b/pkg/core/capability/ssh/ssh.go @@ -2,8 +2,10 @@ package ssh import ( "errors" + "fmt" "reflect" "soarca/pkg/core/capability" + "soarca/pkg/keymanagement" "soarca/pkg/models/cacao" "soarca/pkg/models/execution" "strings" @@ -20,6 +22,7 @@ const ( ) type SshCapability struct { + Keys *keymanagement.KeyManagement } var component = reflect.TypeOf(SshCapability{}).PkgPath() @@ -37,30 +40,24 @@ func (sshCapability *SshCapability) Execute(metadata execution.Metadata, context capability.Context) (cacao.Variables, error) { log.Trace(metadata.ExecutionId) - return execute(context.Command, context.Authentication, context.Target) -} - -func execute(command cacao.Command, - authentication cacao.AuthenticationInformation, - target cacao.AgentTarget) (cacao.Variables, error) { - err := CheckSshAuthenticationInfo(authentication) + err := CheckSshAuthenticationInfo(context.Authentication) if err != nil { log.Error(err) return cacao.NewVariables(), err } - config, err := getConfig(authentication) + config, err := sshCapability.getConfig(context.Authentication) if err != nil { return cacao.NewVariables(), err } - session, client, err := getSession(config, target) + session, client, err := getSession(config, context.Target) if err != nil { return cacao.NewVariables(), err } defer close(client) - return executeCommand(session, command) + return executeCommand(session, context.Command) } func executeCommand(session *ssh.Session, @@ -69,7 +66,7 @@ func executeCommand(session *ssh.Session, response, err := session.Output(StripSshPrepend(command.Command)) if err != nil { - log.Error(err) + log.Error("Output: ", err) return cacao.NewVariables(), err } results := cacao.NewVariables(cacao.Variable{Type: cacao.VariableTypeString, @@ -78,21 +75,41 @@ func executeCommand(session *ssh.Session, log.Trace("Finished ssh execution will return the variables: ", results) sessionErr := session.Close() if sessionErr != nil { - log.Error(sessionErr) + if sessionErr.Error() != "EOF" { + // The ssh api is subtle, and it can happen that we get EOF as an error. + // This is likely not an error, as the session can also be closed by the host. + log.Debug("SSH session close got EOF") + } else { + log.Error("Close: ", sessionErr) + } } - return results, err } -func getConfig(authentication cacao.AuthenticationInformation) (ssh.ClientConfig, error) { +func (sshCapability *SshCapability) getConfig(authentication cacao.AuthenticationInformation) (ssh.ClientConfig, error) { config := ssh.ClientConfig{User: authentication.Username, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: time.Duration(time.Second * 20)} switch authentication.Type { case "user-auth": - config.Auth = []ssh.AuthMethod{ - ssh.Password(authentication.Password)} + if authentication.Kms { + if authentication.KmsKeyIdentifier == "" { + return config, fmt.Errorf("KMS indicated, but no kms_key_identifier given") + } + private_key, err := sshCapability.Keys.GetPrivate(authentication.KmsKeyIdentifier) + if err != nil { + return config, err + } + config.Auth = []ssh.AuthMethod{ + ssh.PublicKeys(private_key), + } + } else if authentication.Password != "" { + config.Auth = []ssh.AuthMethod{ + ssh.Password(authentication.Password)} + } else { + return config, fmt.Errorf("no authentication method given in user-auth") + } return config, nil case "private-key": @@ -156,8 +173,11 @@ func CheckSshAuthenticationInfo(authentication cacao.AuthenticationInformation) switch authentication.Type { case "user-auth": - if strings.TrimSpace(authentication.Password) == "" { - return errors.New("password is empty") + if strings.TrimSpace(authentication.Password) == "" && !authentication.Kms { + return errors.New("password is empty and KMS is not indicated") + } + if authentication.Kms && strings.TrimSpace(authentication.KmsKeyIdentifier) == "" { + return errors.New("KMS is indicated but no identifier is given") } case "private-key": if strings.TrimSpace(authentication.PrivateKey) == "" { diff --git a/pkg/core/capability/ssh/ssh_test.go b/pkg/core/capability/ssh/ssh_test.go index f5aeb5aa0..2ba58aed1 100644 --- a/pkg/core/capability/ssh/ssh_test.go +++ b/pkg/core/capability/ssh/ssh_test.go @@ -29,14 +29,14 @@ func TestAuthenticationValidationUserAuth(t *testing.T) { func TestAuthenticationValidationUserAuthMissingPassword(t *testing.T) { auth := cacao.AuthenticationInformation{Type: "user-auth", Username: "root"} result := CheckSshAuthenticationInfo(auth) - err := errors.New("password is empty") + err := errors.New("password is empty and KMS is not indicated") assert.Equal(t, result, err) } func TestAuthenticationValidationUserAuthSpacesAsPassword(t *testing.T) { auth := cacao.AuthenticationInformation{Type: "user-auth", Username: "root", Password: " "} result := CheckSshAuthenticationInfo(auth) - err := errors.New("password is empty") + err := errors.New("password is empty and KMS is not indicated") assert.Equal(t, result, err) } diff --git a/pkg/keymanagement/keymanagement.go b/pkg/keymanagement/keymanagement.go new file mode 100644 index 000000000..3af0caf11 --- /dev/null +++ b/pkg/keymanagement/keymanagement.go @@ -0,0 +1,85 @@ +package keymanagement + +import ( + "fmt" + "reflect" + keymanagementrepository "soarca/internal/database/keymanagement" + "soarca/internal/logger" + keys "soarca/pkg/models/keymanagement" + + "golang.org/x/crypto/ssh" +) + +var component = reflect.TypeOf(KeyManagement{}).PkgPath() +var log *logger.Log + +func init() { + log = logger.Logger(component, logger.Info, "", logger.Json) +} + +type KeyManagement struct { + database keymanagementrepository.IKeyManagementRepository +} + +func InitKeyManagement(database keymanagementrepository.IKeyManagementRepository) *KeyManagement { + return &KeyManagement{database: database} +} + +func (management *KeyManagement) GetKeyPair(name string) (*keys.KeyPair, error) { + log.Trace("Getting keypair named", name) + keypair, err := management.database.Read(name) + if err != nil { + return nil, err + } + return &keypair, nil +} +func (management *KeyManagement) GetPrivate(name string) (ssh.Signer, error) { + keypair, err := management.GetKeyPair(name) + if err != nil { + return nil, err + } + return keypair.Private, nil +} + +func (management *KeyManagement) Insert(public string, private string, passphrase string, name string) error { + if _, err := management.GetKeyPair(name); err == nil { + return fmt.Errorf("key with name already exists: %s (error: %s)", name, err) + } + return management.insertInternal(public, private, passphrase, name) +} + +func (management *KeyManagement) Update(public string, private string, passphrase string, name string) error { + if _, err := management.GetKeyPair(name); err != nil { + return fmt.Errorf("no such key exists: %s (error: %s)", name, err) + } + return management.insertInternal(public, private, passphrase, name) +} + +func (management *KeyManagement) insertInternal(public string, private string, passphrase string, name string) error { + log.Trace("Inserting keypair named", name) + public_key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public)) + if err != nil { + return fmt.Errorf("parsing public key: %s", err) + } + var private_key ssh.Signer + if passphrase == "" { + private_key, err = ssh.ParsePrivateKey([]byte(private)) + } else { + private_key, err = ssh.ParsePrivateKeyWithPassphrase([]byte(private), []byte(passphrase)) + } + if err != nil { + return fmt.Errorf("parsing private key: %s", err) + } + keypair := keys.KeyPair{Public: public_key, Private: private_key} + return management.database.Create(name, keypair) +} + +func (management *KeyManagement) ListAllNames() ([]string, error) { + log.Trace("Listing all keys") + return management.database.GetKeyNames() +} + +func (management *KeyManagement) Revoke(keyname string) error { + log.Trace("Deleting keypair named", keyname) + return management.database.Delete(keyname) +} diff --git a/pkg/keymanagement/keymanagement_test.go b/pkg/keymanagement/keymanagement_test.go new file mode 100644 index 000000000..f1f07ffd6 --- /dev/null +++ b/pkg/keymanagement/keymanagement_test.go @@ -0,0 +1,59 @@ +package keymanagement + +import ( + "os" + "path" + "slices" + "soarca/internal/database/memory" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testkey string = "test-key" + +func testkey_dir() string { + return path.Join("..", "..", "test", "unittest", "mocks", "mock_utils") +} + +var globalKeyManagement *KeyManagement + +func init() { + globalKeyManagement = InitKeyManagement(memory.NewKeyManagementDatabase()) +} + +func TestRevoke(t *testing.T) { + addKey(t, testkey) + assert.True(t, slices.Contains(allNames(t), testkey)) + assert.Nil(t, globalKeyManagement.Revoke(testkey)) + assert.False(t, slices.Contains(allNames(t), testkey)) +} +func addKey(t *testing.T, keyname string) { + pubkey_path := path.Join(testkey_dir(), "test-key.pub") + privkey_path := path.Join(testkey_dir(), "test-key") + pubkey_file, err := os.Open(pubkey_path) + assert.Nil(t, err) + + privkey_file, err := os.Open(privkey_path) + assert.Nil(t, err) + pubkey_buf := make([]byte, 2048) + privkey_buf := make([]byte, 2048) + _, err = pubkey_file.Read(pubkey_buf) + assert.Nil(t, err) + _, err = privkey_file.Read(privkey_buf) + assert.Nil(t, err) + assert.Nil(t, globalKeyManagement.Insert(string(pubkey_buf), string(privkey_buf), "", keyname)) + assert.Nil(t, privkey_file.Close()) + assert.Nil(t, pubkey_file.Close()) +} + +func allNames(t *testing.T) []string { + allnames, err := globalKeyManagement.ListAllNames() + assert.Nil(t, err) + return allnames +} + +func TestAddKey(t *testing.T) { + addKey(t, testkey) + assert.True(t, slices.Equal(allNames(t), []string{testkey})) +} diff --git a/pkg/models/api/keymanagement.go b/pkg/models/api/keymanagement.go new file mode 100644 index 000000000..41808ed52 --- /dev/null +++ b/pkg/models/api/keymanagement.go @@ -0,0 +1,10 @@ +package api + +type KeyManagementKeyList struct { + Keys []string `json:"keys"` +} +type KeyManagementKey struct { + Private string `json:"private"` + Public string `json:"public"` + Passphrase string `json:"passphrase"` +} diff --git a/pkg/models/keymanagement/keymanagent.go b/pkg/models/keymanagement/keymanagent.go new file mode 100644 index 000000000..a96199619 --- /dev/null +++ b/pkg/models/keymanagement/keymanagent.go @@ -0,0 +1,8 @@ +package keymanagement + +import "golang.org/x/crypto/ssh" + +type KeyPair struct { + Public ssh.PublicKey + Private ssh.Signer +} diff --git a/test/integration/kms/kms_test.go b/test/integration/kms/kms_test.go new file mode 100644 index 000000000..32a80323e --- /dev/null +++ b/test/integration/kms/kms_test.go @@ -0,0 +1,89 @@ +package kms + +import ( + "fmt" + "os" + "path" + "soarca/internal/database/memory" + "soarca/pkg/core/capability" + "soarca/pkg/core/capability/ssh" + "soarca/pkg/keymanagement" + "soarca/pkg/models/cacao" + "soarca/pkg/models/execution" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +var globalKeyManagement *keymanagement.KeyManagement + +func init() { + globalKeyManagement = keymanagement.InitKeyManagement(memory.NewKeyManagementDatabase()) +} + +const testkey string = "test" + +func testkey_dir() string { + return path.Join("..", "..", "..", "deployments", "docker", "testing", "ssh-kms-test") +} +func addTestKey(t *testing.T) { + pubkey_path := path.Join(testkey_dir(), testkey+".pub") + privkey_path := path.Join(testkey_dir(), testkey) + pubkey, err := os.ReadFile(pubkey_path) + assert.Nil(t, err) + privkey, err := os.ReadFile(privkey_path) + assert.Nil(t, err) + assert.Nil(t, globalKeyManagement.Insert(string(pubkey), string(privkey), "", testkey)) +} + +func TestSshConnection(t *testing.T) { + sshCapability := ssh.SshCapability{Keys: globalKeyManagement} + addTestKey(t) + + expectedCommand := cacao.Command{ + Type: "ssh", + Command: "ls -la", + } + + expectedAuthenticationInformation := cacao.AuthenticationInformation{ + ID: "some-authid-1", + Type: "user-auth", + Username: "sshtest", + Kms: true, + KmsKeyIdentifier: testkey, + } + + expectedTarget := cacao.AgentTarget{ + Type: "ssh", + Address: map[cacao.NetAddressType][]string{"ipv4": {"localhost"}}, + Port: "2223", + AuthInfoIdentifier: "some-authid-1", + } + + expectedVariables := cacao.Variable{ + Type: "string", + Name: "var1", + Value: "testing", + } + + var executionId, _ = uuid.Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + var playbookId = "playbook--d09351a2-a075-40c8-8054-0b7c423db83f" + var stepId = "step--81eff59f-d084-4324-9e0a-59e353dbd28f" + var metadata = execution.Metadata{ExecutionId: executionId, PlaybookId: playbookId, StepId: stepId} + data := capability.Context{ + Command: expectedCommand, + Target: expectedTarget, + Authentication: expectedAuthenticationInformation, + Variables: cacao.NewVariables(expectedVariables), + } + results, err := sshCapability.Execute(metadata, + data) + if err != nil { + fmt.Println(err) + t.Fail() + } + + fmt.Println(results) + +} diff --git a/test/unittest/mocks/mock_utils/test-key b/test/unittest/mocks/mock_utils/test-key new file mode 100644 index 000000000..718092253 --- /dev/null +++ b/test/unittest/mocks/mock_utils/test-key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBs4o06933hic/ArsSo0fs9cUTk0AHc2vON1ZqS68Vf7gAAAJhCJkxRQiZM +UQAAAAtzc2gtZWQyNTUxOQAAACBs4o06933hic/ArsSo0fs9cUTk0AHc2vON1ZqS68Vf7g +AAAEBI7PvrlDTFnWuMJHGaLuUkulSpH/Ni378Y2vLZcpldxmzijTr3feGJz8CuxKjR+z1x +ROTQAdza843VmpLrxV/uAAAADnRoaWpzQFBDLTQ0MzE4AQIDBAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/test/unittest/mocks/mock_utils/test-key.pub b/test/unittest/mocks/mock_utils/test-key.pub new file mode 100644 index 000000000..e860bf5dc --- /dev/null +++ b/test/unittest/mocks/mock_utils/test-key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGzijTr3feGJz8CuxKjR+z1xROTQAdza843VmpLrxV/u user@PC