Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
### Server
PORT: 8080
ENABLE_TLS: false
CERT_FILE: "/certs/server.crt"
CERT_KEY_FILE: "/certs/server.key"
CERT_FILE: "./certs/server.crt"
CERT_KEY_FILE: "./certs/server.key"
GIN_MODE: "release"
# Comma-separated, or "*" for any origin.
SOARCA_ALLOWED_ORIGINS: "*"

### Execution
MAX_EXECUTIONS: 10
MAX_REPORTERS: 5
MAX_REPORTERS: 10
# Empty uses the CACAO v2 schema embedded in SOARCA.
VALIDATION_SCHEMA_URL: ""
HTTP_SKIP_CERT_VALIDATION: false
Expand All @@ -26,7 +26,7 @@ DB_PASSWORD: "rootpassword"

### Logging
LOG_GLOBAL_LEVEL: "info"
LOG_MODE: "development"
LOG_MODE: "production"
LOG_FILE_PATH: ""
LOG_FORMAT: "json"

Expand All @@ -53,7 +53,7 @@ OIDC_ISSUER: "https://authentikuri:9443/application/o/soarca/"
OIDC_CLIENT_ID: "some client ID"
OIDC_SKIP_TLS_VERIFY: false
# Members of this group get every permission.
AUTH_GROUP: "soarca_admin"
AUTH_ADMIN_GROUP: "soarca_admin"

# Microsoft Entra ID
# AUTH_ENTRA_TENANT_IDS: "<tenant-id>,<tenant-id>"
Expand All @@ -69,4 +69,4 @@ AUTH_GROUP: "soarca_admin"

### SSH key management
ENABLE_SSH_KMS: false
SSH_KMS_DIR: "deployments/docker/testing/ssh-kms-test/ssh-keystore/"
SSH_KMS_DIR: "deployments/docker/testing/ssh-kms-test/ssh-keystore/"
5 changes: 5 additions & 0 deletions cmd/soarca/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const banner = `

// @title SOARCA API
// @version 1.0.0
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description "Bearer " followed by an access token or API key. Not needed when AUTH_ENABLED is false.
func main() {
fmt.Print(banner)
log.Info("Version: ", Version)
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module soarca
go 1.25.0

require (
github.com/COSSAS/gauth v1.0.3
github.com/caarlos0/env/v11 v11.4.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/eclipse/paho.mqtt.golang v1.5.1
Expand Down Expand Up @@ -53,7 +52,6 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/COSSAS/gauth v1.0.3 h1:Darc5FPOGSEWUE7wRDrqx0w6KZheYjnksHpyCAw6HvM=
github.com/COSSAS/gauth v1.0.3/go.mod h1:JMZoZ7yVFOBJy5y1haZEFoR12zqugDtGnFYJft/uUwY=
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns=
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
Expand Down Expand Up @@ -77,8 +75,6 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
Expand Down
7 changes: 3 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,14 @@ type TheHive struct {
}

// Auth configures who may call the API. With AUTH_ENABLED=true, at least one
// identity provider must be configured. The OIDC_* settings are the ones
// gauth used.
// identity provider must be configured.
type Auth struct {
Enabled bool `env:"AUTH_ENABLED" envDefault:"false"`
Issuer string `env:"OIDC_ISSUER"`
ClientId string `env:"OIDC_CLIENT_ID"`
SkipTlsVerify bool `env:"OIDC_SKIP_TLS_VERIFY" envDefault:"false"`
// Members of AUTH_GROUP get every permission, as they did with gauth.
AdminGroup string `env:"AUTH_GROUP" envDefault:"soarca_admin"`
// Members of AUTH_ADMIN_GROUP get every permission.
AdminGroup string `env:"AUTH_ADMIN_GROUP" envDefault:"soarca_admin"`
Entra Entra
// JSON file with hashed static API keys. Empty disables API keys.
ApiKeysFile string `env:"AUTH_API_KEYS_FILE"`
Expand Down
81 changes: 81 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package config

import (
"encoding/json"
"os"
"reflect"
"regexp"
"strings"
"testing"

"github.com/go-playground/assert/v2"
"github.com/joho/godotenv"
)

// patch mirrors what an API handler does with a request body.
Expand Down Expand Up @@ -203,3 +207,80 @@ func TestLoadIgnoresAuthSettingsWhenAuthDisabled(t *testing.T) {
_, err := Load()
assert.Equal(t, err, nil)
}

// envSettings returns every setting SOARCA reads, keyed by its env tag.
func envSettings() map[string]reflect.StructTag {
settings := map[string]reflect.StructTag{}
var collect func(typ reflect.Type)
collect = func(typ reflect.Type) {
for index := 0; index < typ.NumField(); index++ {
field := typ.Field(index)
if field.Type.Kind() == reflect.Struct {
collect(field.Type)
} else if name := field.Tag.Get("env"); name != "" {
settings[name] = field.Tag
}
}
}
collect(reflect.TypeOf(Static{}))
collect(reflect.TypeOf(Dynamic{}))
return settings
}

// envExample returns every variable in .env.example, commented out or not,
// with its value unquoted.
func envExample(t *testing.T) map[string]string {
content, err := os.ReadFile("../../.env.example")
assert.Equal(t, err, nil)
listed := map[string]string{}
for _, match := range regexp.MustCompile(`(?m)^#?\s*([A-Z][A-Z0-9_]+):\s*(.*)$`).FindAllStringSubmatch(string(content), -1) {
listed[match[1]] = strings.Trim(strings.TrimSpace(match[2]), `"`)
}
return listed
}

// TestEnvExampleListsEverySetting keeps .env.example in step with the
// structs above: every env tag must appear in it, commented out or not,
// and it must not list variables SOARCA doesn't read.
func TestEnvExampleListsEverySetting(t *testing.T) {
listed := envExample(t)
known := envSettings()

for name := range known {
if _, ok := listed[name]; !ok {
t.Errorf("%s is read by SOARCA but missing from .env.example", name)
}
}
for name := range listed {
if _, ok := known[name]; !ok {
t.Errorf("%s is in .env.example but SOARCA doesn't read it", name)
}
}
}

// TestEnvExampleShowsDefaults makes sure .env.example doesn't suggest a
// default that differs from the code. Settings without a default may hold
// any example value.
func TestEnvExampleShowsDefaults(t *testing.T) {
listed := envExample(t)
for name, tag := range envSettings() {
fallback, hasDefault := tag.Lookup("envDefault")
value, ok := listed[name]
if hasDefault && ok && value != fallback {
t.Errorf("%s is %q in .env.example but defaults to %q", name, value, fallback)
}
}
}

// TestEnvExampleLoads checks that the example, as shipped, is valid.
func TestEnvExampleLoads(t *testing.T) {
values, err := godotenv.Read("../../.env.example")
assert.Equal(t, err, nil)
for name, value := range values {
t.Setenv(name, value)
}

config, err := Load()
assert.Equal(t, err, nil)
assert.Equal(t, config.Static.Auth.Issuer, "https://authentikuri:9443/application/o/soarca/")
}
63 changes: 63 additions & 0 deletions internal/controller/authentication.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package controller

import (
"soarca/internal/config"
"soarca/pkg/auth"
"soarca/pkg/auth/providers/apikey"
"soarca/pkg/auth/providers/entra"
"soarca/pkg/auth/providers/oidc"
)

// newAuthenticator builds the authenticator for every provider that
// authConfig enables. config.Load has already checked that each enabled
// provider is complete and that at least one is enabled.
func newAuthenticator(authConfig config.Auth) (auth.IAuthenticator, error) {
if !authConfig.Enabled {
log.Warning("authentication is disabled: every API request is accepted with full permissions")
return auth.Anonymous{}, nil
}

var providers []auth.IIdentityProvider
if authConfig.OidcEnabled() {
provider, err := oidc.New(oidc.Config{
IssuerUrl: authConfig.Issuer,
Audiences: []string{authConfig.ClientId},
Roles: oidc.DefaultRoles(authConfig.AdminGroup),
SkipTlsVerify: authConfig.SkipTlsVerify,
})
if err != nil {
return nil, err
}
providers = append(providers, provider)
log.Info("accepting OIDC tokens from ", authConfig.Issuer)
}

if authConfig.EntraEnabled() {
entraConfig := authConfig.Entra
provider, err := entra.New(entra.Config{
TenantIds: entraConfig.TenantIds,
ApiClientId: entraConfig.ApiClientId,
ApiAppIdUri: entraConfig.ApiAppIdUri,
AllowedClientIds: entraConfig.AllowedClientIds,
RequiredUserScope: entraConfig.RequiredUserScope,
AcceptV1Tokens: entraConfig.AcceptV1Tokens,
Authority: entraConfig.Authority,
})
if err != nil {
return nil, err
}
providers = append(providers, provider)
log.Info("accepting Entra ID tokens from tenants ", entraConfig.TenantIds)
}

if authConfig.ApiKeysFile != "" {
store, err := apikey.LoadFile(authConfig.ApiKeysFile)
if err != nil {
return nil, err
}
providers = append(providers, apikey.New(store))
log.Info("accepting ", len(store), " API keys from ", authConfig.ApiKeysFile)
}

return auth.New(providers)
}
93 changes: 93 additions & 0 deletions internal/controller/authentication_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package controller

import (
"context"
"os"
"path/filepath"
"testing"

"soarca/internal/config"
"soarca/pkg/auth"
"soarca/pkg/auth/providers/apikey"
"soarca/pkg/auth/roles"

"github.com/go-playground/assert/v2"
)

func writeKeys(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "api-keys.json")
assert.Equal(t, os.WriteFile(path, []byte(content), 0o600), nil)
return path
}

func TestDisabledAuthIsAnonymous(t *testing.T) {
authenticator, err := newAuthenticator(config.Auth{Enabled: false, ApiKeysFile: "/does/not/exist"})
assert.Equal(t, err, nil)

principal, err := authenticator.Authenticate(context.Background(), "")
assert.Equal(t, err, nil)
assert.Equal(t, principal.Provider, auth.AnonymousName)
assert.Equal(t, principal.Has(roles.Admin), true)
}

func TestApiKeysFromFile(t *testing.T) {
plaintext, hash, err := apikey.Generate()
assert.Equal(t, err, nil)
path := writeKeys(t, `[{"name":"webhook","sha256":"`+hash+`","permissions":["playbook:execute"],"expires":"2999-01-01T00:00:00Z"}]`)

authenticator, err := newAuthenticator(config.Auth{Enabled: true, ApiKeysFile: path})
assert.Equal(t, err, nil)

principal, err := authenticator.Authenticate(context.Background(), plaintext)
assert.Equal(t, err, nil)
assert.Equal(t, principal.Provider, apikey.Name)
assert.Equal(t, principal.Has(roles.PlaybookExecute), true)
assert.Equal(t, principal.Has(roles.PlaybookWrite), false)
}

func TestInvalidApiKeysFileStopsStartup(t *testing.T) {
_, err := newAuthenticator(config.Auth{Enabled: true, ApiKeysFile: "/does/not/exist"})
assert.NotEqual(t, err, nil)

_, err = newAuthenticator(config.Auth{Enabled: true, ApiKeysFile: writeKeys(t, `[{"name":"no-expiry","sha256":"x"}]`)})
assert.NotEqual(t, err, nil)
}

func TestEveryProviderTogether(t *testing.T) {
_, hash, _ := apikey.Generate()
authConfig := config.Auth{
Enabled: true,
Issuer: "https://authentik.example.com/application/o/soarca/",
ClientId: "soarca",
AdminGroup: "soarca_admin",
Entra: config.Entra{
TenantIds: []string{"11111111-1111-1111-1111-111111111111"},
ApiClientId: "aaaaaaaa-0000-0000-0000-000000000001",
RequiredUserScope: "access_as_user",
Authority: "https://login.microsoftonline.com",
},
ApiKeysFile: writeKeys(t, `[{"name":"webhook","sha256":"`+hash+`","permissions":["playbook:read"],"expires":"2999-01-01T00:00:00Z"}]`),
}

authenticator, err := newAuthenticator(authConfig)
assert.Equal(t, err, nil)

// Neither identity provider is contacted at startup, and a credential
// no provider handles is rejected without reaching out either.
_, err = authenticator.Authenticate(context.Background(), "")
assert.NotEqual(t, err, nil)
}

func TestAdminApiKeyReachesEveryPermission(t *testing.T) {
plaintext, hash, _ := apikey.Generate()
path := writeKeys(t, `[{"name":"admin","sha256":"`+hash+`","permissions":["soarca:admin"],"expires":"2999-01-01T00:00:00Z"}]`)

authenticator, err := newAuthenticator(config.Auth{Enabled: true, ApiKeysFile: path})
assert.Equal(t, err, nil)

principal, err := authenticator.Authenticate(context.Background(), plaintext)
assert.Equal(t, err, nil)
for _, permission := range roles.All() {
assert.Equal(t, principal.Has(permission), true)
}
}
Loading
Loading