Files
Charon/backend/internal/crowdsec/console_enroll_test.go
GitHub Actions 25082778c9 feat(cerberus): integrate Cerberus security features (WAF, ACLs, rate limiting, CrowdSec)
- Implement GeoIPService for IP-to-country lookups with comprehensive error handling.
- Add tests for GeoIPService covering various scenarios including invalid IPs and database loading.
- Extend AccessListService to handle GeoIP service integration, including graceful degradation when GeoIP service is unavailable.
- Introduce new tests for AccessListService to validate geo ACL behavior and country code parsing.
- Update SecurityService to include new fields for WAF configuration and enhance decision logging functionality.
- Add extensive tests for SecurityService covering rule set management and decision logging.
- Create a detailed Security Coverage QA Plan to ensure 100% code coverage for security-related functionality.
2025-12-12 17:56:30 +00:00

484 lines
15 KiB
Go

package crowdsec
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type stubEnvExecutor struct {
responses []struct {
out []byte
err error
}
defaultResponse struct {
out []byte
err error
}
calls []struct {
name string
args []string
env map[string]string
}
}
func (s *stubEnvExecutor) ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error) {
s.calls = append(s.calls, struct {
name string
args []string
env map[string]string
}{name, args, env})
if len(s.calls) <= len(s.responses) {
resp := s.responses[len(s.calls)-1]
return resp.out, resp.err
}
return s.defaultResponse.out, s.defaultResponse.err
}
func (s *stubEnvExecutor) callCount() int {
return len(s.calls)
}
func (s *stubEnvExecutor) lastArgs() []string {
if len(s.calls) == 0 {
return nil
}
return s.calls[len(s.calls)-1].args
}
func openConsoleTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.CrowdsecConsoleEnrollment{}))
return db
}
func TestConsoleEnrollSuccess(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{} // Default success
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "super-secret")
svc.nowFn = func() time.Time { return time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) }
status, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "abc123def4g", Tenant: "tenant-a", AgentName: "agent-one"})
require.NoError(t, err)
require.Equal(t, consoleStatusEnrolled, status.Status)
require.True(t, status.KeyPresent)
require.NotEmpty(t, status.CorrelationID)
// Expect 2 calls: capi register, then console enroll
require.Equal(t, 2, exec.callCount())
require.Equal(t, []string{"capi", "register"}, exec.calls[0].args)
require.Equal(t, "abc123def4g", exec.lastArgs()[len(exec.lastArgs())-1])
var rec models.CrowdsecConsoleEnrollment
require.NoError(t, db.First(&rec).Error)
require.NotEqual(t, "abc123def4g", rec.EncryptedEnrollKey)
plain, decErr := svc.decrypt(rec.EncryptedEnrollKey)
require.NoError(t, decErr)
require.Equal(t, "abc123def4g", plain)
}
func TestConsoleEnrollFailureRedactsSecret(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{
responses: []struct {
out []byte
err error
}{
{out: nil, err: nil}, // capi register success
{out: []byte("invalid secretKEY123"), err: fmt.Errorf("bad key secretKEY123")}, // enroll failure
},
}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "redactme")
status, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "secretKEY123", Tenant: "tenant", AgentName: "agent"})
require.Error(t, err)
require.Equal(t, consoleStatusFailed, status.Status)
require.NotContains(t, status.LastError, "secretKEY123")
require.NotContains(t, err.Error(), "secretKEY123")
}
func TestConsoleEnrollIdempotentWhenAlreadyEnrolled(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
_, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "abc123def4g", Tenant: "tenant", AgentName: "agent"})
require.NoError(t, err)
require.Equal(t, 2, exec.callCount()) // capi register + enroll
status, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "ignoredignored", Tenant: "tenant", AgentName: "agent"})
require.NoError(t, err)
require.Equal(t, consoleStatusEnrolled, status.Status)
// Should call capi register again (because file missing in temp dir), but then stop because already enrolled
require.Equal(t, 3, exec.callCount(), "second call should check capi then stop")
require.Equal(t, []string{"capi", "register"}, exec.lastArgs())
}
func TestConsoleEnrollBlockedWhenInProgress(t *testing.T) {
db := openConsoleTestDB(t)
rec := models.CrowdsecConsoleEnrollment{UUID: "u1", Status: consoleStatusEnrolling}
require.NoError(t, db.Create(&rec).Error)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
status, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "abc123def4g", Tenant: "tenant", AgentName: "agent"})
require.Error(t, err)
require.Equal(t, consoleStatusEnrolling, status.Status)
// capi register is called before status check
require.Equal(t, 1, exec.callCount())
require.Equal(t, []string{"capi", "register"}, exec.lastArgs())
}
func TestConsoleEnrollNormalizesFullCommand(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
status, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "sudo cscli console enroll cmj0r0uer000202lebd5luvxh", Tenant: "tenant", AgentName: "agent"})
require.NoError(t, err)
require.Equal(t, consoleStatusEnrolled, status.Status)
require.Equal(t, 2, exec.callCount())
require.Equal(t, "cmj0r0uer000202lebd5luvxh", exec.lastArgs()[len(exec.lastArgs())-1])
}
func TestConsoleEnrollRejectsUnsafeInput(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
_, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{EnrollmentKey: "cscli console enroll cmj0r0uer000202lebd5luvxh; rm -rf /", Tenant: "tenant", AgentName: "agent"})
require.Error(t, err)
require.Contains(t, strings.ToLower(err.Error()), "invalid enrollment key")
require.Equal(t, 0, exec.callCount())
}
func TestConsoleEnrollDoesNotPassTenant(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
// Even if tenant is provided in the request
req := ConsoleEnrollRequest{
EnrollmentKey: "abc123def4g",
Tenant: "some-tenant-id",
AgentName: "agent-one",
}
status, err := svc.Enroll(context.Background(), req)
require.NoError(t, err)
require.Equal(t, consoleStatusEnrolled, status.Status)
// Verify that --tenant is NOT passed to the command arguments
require.Equal(t, 2, exec.callCount())
require.NotContains(t, exec.lastArgs(), "--tenant")
// Also verify that the tenant value itself is not passed as a standalone arg just in case
require.NotContains(t, exec.lastArgs(), "some-tenant-id")
}
// ============================================
// SecureCommandExecutor Tests
// ============================================
func TestSecureCommandExecutorExecuteWithEnv(t *testing.T) {
// Skip this test if not on a system with echo command
exec := &SecureCommandExecutor{}
ctx := context.Background()
t.Run("executes command successfully", func(t *testing.T) {
output, err := exec.ExecuteWithEnv(ctx, "echo", []string{"hello"}, nil)
require.NoError(t, err)
require.Contains(t, string(output), "hello")
})
t.Run("passes environment variables", func(t *testing.T) {
output, err := exec.ExecuteWithEnv(ctx, "sh", []string{"-c", "echo $TEST_VAR"}, map[string]string{"TEST_VAR": "test_value"})
require.NoError(t, err)
require.Contains(t, string(output), "test_value")
})
t.Run("handles empty env map", func(t *testing.T) {
output, err := exec.ExecuteWithEnv(ctx, "echo", []string{"no-env"}, map[string]string{})
require.NoError(t, err)
require.Contains(t, string(output), "no-env")
})
t.Run("handles command failure", func(t *testing.T) {
_, err := exec.ExecuteWithEnv(ctx, "false", nil, nil)
require.Error(t, err)
})
t.Run("handles context timeout", func(t *testing.T) {
ctxTimeout, cancel := context.WithTimeout(ctx, time.Nanosecond)
defer cancel()
time.Sleep(time.Millisecond) // ensure timeout
_, err := exec.ExecuteWithEnv(ctxTimeout, "sleep", []string{"10"}, nil)
require.Error(t, err)
})
}
// ============================================
// formatEnv Tests
// ============================================
func TestFormatEnv(t *testing.T) {
t.Run("formats single env var", func(t *testing.T) {
result := formatEnv(map[string]string{"KEY": "value"})
require.Len(t, result, 1)
require.Equal(t, "KEY=value", result[0])
})
t.Run("formats multiple env vars", func(t *testing.T) {
result := formatEnv(map[string]string{
"KEY1": "value1",
"KEY2": "value2",
})
require.Len(t, result, 2)
require.Contains(t, result, "KEY1=value1")
require.Contains(t, result, "KEY2=value2")
})
t.Run("handles empty map", func(t *testing.T) {
result := formatEnv(map[string]string{})
require.Nil(t, result)
})
t.Run("handles nil map", func(t *testing.T) {
result := formatEnv(nil)
require.Nil(t, result)
})
t.Run("handles special characters", func(t *testing.T) {
result := formatEnv(map[string]string{"KEY": "value with spaces"})
require.Len(t, result, 1)
require.Equal(t, "KEY=value with spaces", result[0])
})
}
// ============================================
// ConsoleEnrollmentService.Status Tests
// ============================================
func TestConsoleEnrollmentStatus(t *testing.T) {
t.Run("returns not_enrolled for new service", func(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
status, err := svc.Status(context.Background())
require.NoError(t, err)
require.Equal(t, consoleStatusNotEnrolled, status.Status)
})
t.Run("returns enrolled status after enrollment", func(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
// First enroll
_, err := svc.Enroll(context.Background(), ConsoleEnrollRequest{
EnrollmentKey: "abc123def4g",
AgentName: "test-agent",
})
require.NoError(t, err)
// Then check status
status, err := svc.Status(context.Background())
require.NoError(t, err)
require.Equal(t, consoleStatusEnrolled, status.Status)
require.Equal(t, "test-agent", status.AgentName)
require.True(t, status.KeyPresent)
require.NotNil(t, status.EnrolledAt)
})
t.Run("returns failed status after failed enrollment", func(t *testing.T) {
db := openConsoleTestDB(t)
exec := &stubEnvExecutor{
responses: []struct {
out []byte
err error
}{
{out: nil, err: nil}, // capi register success
{out: []byte("error"), err: fmt.Errorf("enroll failed")}, // enroll failure
},
}
svc := NewConsoleEnrollmentService(db, exec, t.TempDir(), "secret")
// Attempt enrollment (will fail)
_, _ = svc.Enroll(context.Background(), ConsoleEnrollRequest{
EnrollmentKey: "abc123def4g",
AgentName: "test-agent",
})
// Check status
status, err := svc.Status(context.Background())
require.NoError(t, err)
require.Equal(t, consoleStatusFailed, status.Status)
require.NotEmpty(t, status.LastError)
})
}
// ============================================
// deriveKey Tests
// ============================================
func TestDeriveKey(t *testing.T) {
t.Run("derives consistent key", func(t *testing.T) {
key1 := deriveKey("secret")
key2 := deriveKey("secret")
require.Equal(t, key1, key2)
})
t.Run("derives different keys for different secrets", func(t *testing.T) {
key1 := deriveKey("secret1")
key2 := deriveKey("secret2")
require.NotEqual(t, key1, key2)
})
t.Run("uses default for empty secret", func(t *testing.T) {
key := deriveKey("")
require.Len(t, key, 32) // SHA-256 output
})
}
// ============================================
// normalizeEnrollmentKey Tests
// ============================================
func TestNormalizeEnrollmentKey(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectErr bool
}{
{
name: "valid raw key",
input: "abc123def4g",
expected: "abc123def4g",
},
{
name: "full command with sudo",
input: "sudo cscli console enroll abc123def4g",
expected: "abc123def4g",
},
{
name: "full command without sudo",
input: "cscli console enroll abc123def4g",
expected: "abc123def4g",
},
{
name: "key with whitespace",
input: " abc123def4g ",
expected: "abc123def4g",
},
{
name: "empty key",
input: "",
expectErr: true,
},
{
name: "only whitespace",
input: " ",
expectErr: true,
},
{
name: "invalid format",
input: "invalid key format here",
expectErr: true,
},
{
name: "injection attempt",
input: "abc123; rm -rf /",
expectErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := normalizeEnrollmentKey(tc.input)
if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
// ============================================
// redactSecret Tests
// ============================================
func TestRedactSecret(t *testing.T) {
t.Run("redacts secret from message", func(t *testing.T) {
result := redactSecret("Error: invalid token secretKEY123", "secretKEY123")
require.Equal(t, "Error: invalid token <redacted>", result)
})
t.Run("handles empty secret", func(t *testing.T) {
result := redactSecret("Error message", "")
require.Equal(t, "Error message", result)
})
t.Run("handles secret not in message", func(t *testing.T) {
result := redactSecret("Error message", "secret")
require.Equal(t, "Error message", result)
})
t.Run("redacts multiple occurrences", func(t *testing.T) {
result := redactSecret("Token ABC123 failed, please retry with ABC123", "ABC123")
require.Equal(t, "Token <redacted> failed, please retry with <redacted>", result)
})
}
// ============================================
// Encryption Tests
// ============================================
func TestEncryptDecrypt(t *testing.T) {
db := openConsoleTestDB(t)
svc := NewConsoleEnrollmentService(db, &stubEnvExecutor{}, t.TempDir(), "test-secret")
t.Run("encrypts and decrypts successfully", func(t *testing.T) {
original := "sensitive-enrollment-key"
encrypted, err := svc.encrypt(original)
require.NoError(t, err)
require.NotEqual(t, original, encrypted)
decrypted, err := svc.decrypt(encrypted)
require.NoError(t, err)
require.Equal(t, original, decrypted)
})
t.Run("handles empty string", func(t *testing.T) {
encrypted, err := svc.encrypt("")
require.NoError(t, err)
require.Empty(t, encrypted)
decrypted, err := svc.decrypt("")
require.NoError(t, err)
require.Empty(t, decrypted)
})
t.Run("different encryptions produce different ciphertext", func(t *testing.T) {
original := "test-key"
encrypted1, _ := svc.encrypt(original)
encrypted2, _ := svc.encrypt(original)
require.NotEqual(t, encrypted1, encrypted2, "encryptions should use different nonces")
})
}