From c1f346a98678e78396c1f0282c0f59286eb30369 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Dec 2025 01:02:05 +0100 Subject: [PATCH] feat: Add Lua/OpenResty realtime security capture - Add realtime_capture config option for OpenResty Lua-based event capture - Add nginx.lua.conf template for OpenResty with Lua security module - Add security event ingest API endpoint for Lua module - Add enhanced security stats with trends, top deployments, critical events - Add e2e tests for security and Lua features - Update CI with security-tests and lua-tests jobs - Use ghcr.io/flatrun/openresty image with lua-resty-http pre-installed Signed-off-by: nfebe --- .github/workflows/ci.yml | 42 ++++ Makefile | 56 ++++- config.example.yml | 9 + internal/api/security_handlers.go | 39 +++ internal/api/server.go | 72 ++++++ internal/infra/manager.go | 95 +++++++ internal/infra/manager_test.go | 190 ++++++++++++++ internal/security/db.go | 40 +++ internal/security/manager.go | 19 +- internal/security/models.go | 31 ++- internal/security/nginx.go | 27 ++ internal/security/nginx_test.go | 157 ++++++++++++ pkg/config/config.go | 1 + templates/infra/nginx/nginx.conf | 24 +- templates/infra/nginx/nginx.lua.conf | 61 +++++ templates/templates.go | 12 + test/e2e/Dockerfile.lua | 27 ++ test/e2e/Dockerfile.security | 27 ++ test/e2e/config.lua.yml | 36 +++ test/e2e/config.security.yml | 35 +++ test/e2e/docker-compose.lua.yml | 48 ++++ test/e2e/docker-compose.security.yml | 45 ++++ test/e2e/lua_test.go | 360 +++++++++++++++++++++++++++ test/e2e/nginx/lua/default.conf | 43 ++++ test/e2e/nginx/lua/nginx.conf | 50 ++++ test/e2e/nginx/lua/security.lua | 180 ++++++++++++++ test/e2e/nginx/security/default.conf | 14 ++ test/e2e/nginx/security/nginx.conf | 42 ++++ test/e2e/security_test.go | 164 ++++++++++++ 29 files changed, 1913 insertions(+), 33 deletions(-) create mode 100644 internal/infra/manager_test.go create mode 100644 internal/security/nginx_test.go create mode 100644 templates/infra/nginx/nginx.lua.conf create mode 100644 test/e2e/Dockerfile.lua create mode 100644 test/e2e/Dockerfile.security create mode 100644 test/e2e/config.lua.yml create mode 100644 test/e2e/config.security.yml create mode 100644 test/e2e/docker-compose.lua.yml create mode 100644 test/e2e/docker-compose.security.yml create mode 100644 test/e2e/lua_test.go create mode 100644 test/e2e/nginx/lua/default.conf create mode 100644 test/e2e/nginx/lua/nginx.conf create mode 100644 test/e2e/nginx/lua/security.lua create mode 100644 test/e2e/nginx/security/default.conf create mode 100644 test/e2e/nginx/security/nginx.conf create mode 100644 test/e2e/security_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d55277..9ac62a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,45 @@ jobs: - name: Cleanup E2E environment if: always() run: make test-e2e-cleanup + + security-tests: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run Security E2E tests + run: make test-e2e-security + + - name: Cleanup on failure + if: failure() + run: make test-e2e-security-cleanup + + lua-tests: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run Lua/OpenResty E2E tests + run: make test-e2e-lua + + - name: Cleanup on failure + if: failure() + run: make test-e2e-lua-cleanup diff --git a/Makefile b/Makefile index 01e1346..11195ac 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run test test-unit test-e2e test-e2e-docker test-e2e-setup test-e2e-run test-e2e-short test-e2e-cleanup test-coverage clean deps lint lint-install fmt vet +.PHONY: help build run test test-unit test-e2e test-e2e-docker test-e2e-setup test-e2e-run test-e2e-short test-e2e-cleanup test-e2e-security test-e2e-security-setup test-e2e-security-cleanup test-e2e-lua test-e2e-lua-setup test-e2e-lua-cleanup test-coverage clean deps lint lint-install fmt vet BINARY_NAME=flatrun-agent VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") @@ -8,6 +8,8 @@ LDFLAGS=-ldflags "-X github.com/flatrun/agent/pkg/version.Version=$(VERSION) -X E2E_API_URL?=http://localhost:18090/api E2E_DEPLOYMENTS_PATH?=/tmp/flatrun-e2e-deployments +E2E_SECURITY_PATH?=/tmp/flatrun-e2e-security +E2E_LUA_PATH?=/tmp/flatrun-e2e-lua help: @echo "FlatRun Agent - Build commands" @@ -23,6 +25,8 @@ help: @echo "make test-e2e-run - Run E2E tests against Docker environment" @echo "make test-e2e-short - Run E2E tests (skip long-running)" @echo "make test-e2e-cleanup - Clean up Docker test environment" + @echo "make test-e2e-security - Run security E2E tests (static configs)" + @echo "make test-e2e-lua - Run Lua/OpenResty E2E tests (realtime capture)" @echo "make test-coverage - Run tests with coverage report" @echo "make lint - Run golangci-lint" @echo "make lint-install - Install golangci-lint" @@ -80,13 +84,61 @@ test-e2e-short: FLATRUN_API_URL=$(E2E_API_URL) FLATRUN_DEPLOYMENTS_PATH=$(E2E_DEPLOYMENTS_PATH) go test -v -short -timeout 5m ./test/e2e/... test-e2e-cleanup: - @echo "Cleaning up E2E test environment..." + @echo "Cleaning up all E2E test environments..." cd test/e2e && docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true + cd test/e2e && docker compose -f docker-compose.security.yml down -v --remove-orphans 2>/dev/null || true + cd test/e2e && docker compose -f docker-compose.lua.yml down -v --remove-orphans 2>/dev/null || true @docker network rm proxy 2>/dev/null || true @docker network rm database 2>/dev/null || true @rm -rf $(E2E_DEPLOYMENTS_PATH)/* 2>/dev/null || true + @rm -rf $(E2E_SECURITY_PATH)/* 2>/dev/null || true + @rm -rf $(E2E_LUA_PATH)/* 2>/dev/null || true @echo "Cleanup complete" +test-e2e-security-setup: + @echo "Setting up Security E2E test environment..." + @mkdir -p $(E2E_SECURITY_PATH)/nginx/conf.d + @chmod -R 777 $(E2E_SECURITY_PATH) 2>/dev/null || true + @docker network create proxy 2>/dev/null || true + cd test/e2e && docker compose -f docker-compose.security.yml up -d --build + @echo "Waiting for services to be healthy..." + @timeout 120 bash -c 'until docker exec flatrun-e2e-security-agent wget -q -O /dev/null http://127.0.0.1:8090/api/health 2>/dev/null; do sleep 2; done' || (echo "Security agent failed to start" && exit 1) + @chmod -R 777 $(E2E_SECURITY_PATH) 2>/dev/null || true + @echo "Security E2E environment ready" + +test-e2e-security-cleanup: + @echo "Cleaning up Security E2E test environment..." + cd test/e2e && docker compose -f docker-compose.security.yml down -v --remove-orphans 2>/dev/null || true + @rm -rf $(E2E_SECURITY_PATH)/* 2>/dev/null || true + @echo "Security cleanup complete" + +test-e2e-security: test-e2e-security-setup + @echo "Running Security E2E tests..." + FLATRUN_SECURITY_TEST=true go test -v -timeout 5m ./test/e2e/... -run TestSecurityConfigFilesCreated + @$(MAKE) test-e2e-security-cleanup + +test-e2e-lua-setup: + @echo "Setting up Lua/OpenResty E2E test environment..." + @mkdir -p $(E2E_LUA_PATH)/nginx/conf.d + @chmod -R 777 $(E2E_LUA_PATH) 2>/dev/null || true + @docker network create proxy 2>/dev/null || true + cd test/e2e && docker compose -f docker-compose.lua.yml up -d --build + @echo "Waiting for services to be healthy..." + @timeout 120 bash -c 'until docker exec flatrun-e2e-lua-agent wget -q -O /dev/null http://127.0.0.1:8090/api/health 2>/dev/null; do sleep 2; done' || (echo "Lua agent failed to start" && exit 1) + @chmod -R 777 $(E2E_LUA_PATH) 2>/dev/null || true + @echo "Lua/OpenResty E2E environment ready" + +test-e2e-lua-cleanup: + @echo "Cleaning up Lua/OpenResty E2E test environment..." + cd test/e2e && docker compose -f docker-compose.lua.yml down -v --remove-orphans 2>/dev/null || true + @rm -rf $(E2E_LUA_PATH)/* 2>/dev/null || true + @echo "Lua cleanup complete" + +test-e2e-lua: test-e2e-lua-setup + @echo "Running Lua/OpenResty E2E tests..." + FLATRUN_LUA_TEST=true go test -v -timeout 5m ./test/e2e/... -run TestLuaRealtimeCapture + @$(MAKE) test-e2e-lua-cleanup + test-coverage: @echo "Running tests with coverage..." go test -coverprofile=coverage.out ./... diff --git a/config.example.yml b/config.example.yml index ad09831..e8a3d73 100644 --- a/config.example.yml +++ b/config.example.yml @@ -58,3 +58,12 @@ infrastructure: host: "" port: 6379 password: "" +security: + enabled: true + realtime_capture: false + scan_interval: 30s + retention_days: 30 + rate_threshold: 100 + auto_block_enabled: false + auto_block_threshold: 50 + auto_block_duration: 24h0m0s diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index aa2ea00..58a7d5c 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/flatrun/agent/internal/security" + "github.com/flatrun/agent/pkg/config" "github.com/flatrun/agent/pkg/models" "github.com/gin-gonic/gin" ) @@ -420,3 +421,41 @@ func (s *Server) getDeploymentSecurityEvents(c *gin.Context) { "deployment": name, }) } + +// getRealtimeCaptureStatus returns the current realtime capture status +func (s *Server) getRealtimeCaptureStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "enabled": s.config.Security.Enabled, + "realtime_capture": s.config.Security.RealtimeCapture, + }) +} + +// setRealtimeCaptureStatus enables or disables realtime capture +func (s *Server) setRealtimeCaptureStatus(c *gin.Context) { + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := s.infraManager.SetNginxRealtimeCapture(req.Enabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + s.config.Security.RealtimeCapture = req.Enabled + + if s.configPath != "" { + if err := config.Save(s.config, s.configPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Config updated but failed to save: " + err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "realtime_capture": req.Enabled, + "message": "Realtime capture " + map[bool]string{true: "enabled", false: "disabled"}[req.Enabled], + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index e85bcdc..c4fe02f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -88,6 +88,14 @@ func New(cfg *config.Config, configPath string) *Server { securityManager, err = security.NewManager(cfg.DeploymentsPath) if err != nil { log.Printf("Warning: Failed to initialize security manager: %v", err) + } else { + nginxConfigPath := cfg.Nginx.ConfigPath + if nginxConfigPath == "" { + nginxConfigPath = filepath.Join(cfg.DeploymentsPath, "nginx", "conf.d") + } + if err := securityManager.InitNginxConfigs(nginxConfigPath); err != nil { + log.Printf("Warning: Failed to initialize security nginx configs: %v", err) + } } } @@ -255,6 +263,8 @@ func (s *Server) setupRoutes() { protected.POST("/security/protected-routes", s.addProtectedRoute) protected.PUT("/security/protected-routes/:id", s.updateProtectedRoute) protected.DELETE("/security/protected-routes/:id", s.deleteProtectedRoute) + protected.GET("/security/realtime-capture", s.getRealtimeCaptureStatus) + protected.PUT("/security/realtime-capture", s.setRealtimeCaptureStatus) protected.GET("/deployments/:name/security", s.getDeploymentSecurity) protected.PUT("/deployments/:name/security", s.updateDeploymentSecurity) protected.GET("/deployments/:name/security/events", s.getDeploymentSecurityEvents) @@ -1175,6 +1185,16 @@ func (s *Server) getSettings(c *gin.Context) { "port": s.config.Infrastructure.Redis.Port, }, }, + "security": gin.H{ + "enabled": s.config.Security.Enabled, + "realtime_capture": s.config.Security.RealtimeCapture, + "scan_interval": s.config.Security.ScanInterval.String(), + "retention_days": s.config.Security.RetentionDays, + "rate_threshold": s.config.Security.RateThreshold, + "auto_block_enabled": s.config.Security.AutoBlockEnabled, + "auto_block_threshold": s.config.Security.AutoBlockThreshold, + "auto_block_duration": s.config.Security.AutoBlockDuration.String(), + }, }, }) } @@ -1224,6 +1244,16 @@ func (s *Server) updateSettings(c *gin.Context) { Password string `json:"password"` } `json:"redis,omitempty"` } `json:"infrastructure,omitempty"` + Security *struct { + Enabled bool `json:"enabled"` + RealtimeCapture bool `json:"realtime_capture"` + ScanInterval string `json:"scan_interval"` + RetentionDays int `json:"retention_days"` + RateThreshold int `json:"rate_threshold"` + AutoBlockEnabled bool `json:"auto_block_enabled"` + AutoBlockThreshold int `json:"auto_block_threshold"` + AutoBlockDuration string `json:"auto_block_duration"` + } `json:"security,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1312,6 +1342,38 @@ func (s *Server) updateSettings(c *gin.Context) { } } + if req.Security != nil { + prevEnabled := s.config.Security.Enabled + prevRealtimeCapture := s.config.Security.RealtimeCapture + s.config.Security.Enabled = req.Security.Enabled + s.config.Security.RealtimeCapture = req.Security.RealtimeCapture + s.config.Security.AutoBlockEnabled = req.Security.AutoBlockEnabled + if req.Security.RetentionDays > 0 { + s.config.Security.RetentionDays = req.Security.RetentionDays + } + if req.Security.RateThreshold > 0 { + s.config.Security.RateThreshold = req.Security.RateThreshold + } + if req.Security.AutoBlockThreshold > 0 { + s.config.Security.AutoBlockThreshold = req.Security.AutoBlockThreshold + } + if req.Security.ScanInterval != "" { + if d, err := time.ParseDuration(req.Security.ScanInterval); err == nil { + s.config.Security.ScanInterval = d + } + } + if req.Security.AutoBlockDuration != "" { + if d, err := time.ParseDuration(req.Security.AutoBlockDuration); err == nil { + s.config.Security.AutoBlockDuration = d + } + } + if prevRealtimeCapture != s.config.Security.RealtimeCapture || prevEnabled != s.config.Security.Enabled { + if err := s.infraManager.SetNginxRealtimeCapture(s.config.Security.RealtimeCapture && s.config.Security.Enabled); err != nil { + log.Printf("Warning: failed to update nginx realtime capture: %v", err) + } + } + } + s.infraManager.UpdateConfig(s.config) s.proxyOrchestrator.UpdateConfig(s.config) @@ -1364,6 +1426,16 @@ func (s *Server) updateSettings(c *gin.Context) { "port": s.config.Infrastructure.Redis.Port, }, }, + "security": gin.H{ + "enabled": s.config.Security.Enabled, + "realtime_capture": s.config.Security.RealtimeCapture, + "scan_interval": s.config.Security.ScanInterval.String(), + "retention_days": s.config.Security.RetentionDays, + "rate_threshold": s.config.Security.RateThreshold, + "auto_block_enabled": s.config.Security.AutoBlockEnabled, + "auto_block_threshold": s.config.Security.AutoBlockThreshold, + "auto_block_duration": s.config.Security.AutoBlockDuration.String(), + }, }, }) } diff --git a/internal/infra/manager.go b/internal/infra/manager.go index e68d407..24117fc 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -4,13 +4,16 @@ import ( "bytes" "encoding/json" "fmt" + "os" "os/exec" + "path/filepath" "strings" "sync" "time" "github.com/flatrun/agent/pkg/config" "github.com/flatrun/agent/pkg/models" + "github.com/flatrun/agent/templates" ) type Manager struct { @@ -300,3 +303,95 @@ func (m *Manager) GetStats() (*InfraStats, error) { return stats, nil } + +func (m *Manager) SetNginxRealtimeCapture(enabled bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + nginxDir := m.getNginxDir() + if nginxDir == "" { + return fmt.Errorf("nginx config path not configured") + } + + if err := os.MkdirAll(nginxDir, 0755); err != nil { + return fmt.Errorf("failed to create nginx directory: %w", err) + } + + nginxConf, err := templates.GetNginxConfig(enabled) + if err != nil { + return fmt.Errorf("failed to get nginx config template: %w", err) + } + + confPath := filepath.Join(nginxDir, "nginx.conf") + if err := os.WriteFile(confPath, nginxConf, 0644); err != nil { + return fmt.Errorf("failed to write nginx.conf: %w", err) + } + + if enabled { + luaDir := filepath.Join(nginxDir, "lua") + if err := os.MkdirAll(luaDir, 0755); err != nil { + return fmt.Errorf("failed to create lua directory: %w", err) + } + + securityLua, err := templates.GetNginxSecurityLua() + if err != nil { + return fmt.Errorf("failed to get security.lua template: %w", err) + } + + luaPath := filepath.Join(luaDir, "security.lua") + if err := os.WriteFile(luaPath, securityLua, 0644); err != nil { + return fmt.Errorf("failed to write security.lua: %w", err) + } + } + + confDir := filepath.Join(nginxDir, "conf.d") + if err := os.MkdirAll(confDir, 0755); err != nil { + return fmt.Errorf("failed to create conf.d directory: %w", err) + } + + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + content := "# Auto-generated by FlatRun Security\n# No blocked IPs\n" + if err := os.WriteFile(blockedIPsPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to create blocked_ips.conf: %w", err) + } + } + + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + content := "# Auto-generated by FlatRun Security\n# No rate limit zones defined\n" + if err := os.WriteFile(rateLimitsPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to create rate_limits.conf: %w", err) + } + } + + if m.config.Nginx.ContainerName != "" { + if err := m.reloadNginx(); err != nil { + return fmt.Errorf("failed to reload nginx: %w", err) + } + } + + return nil +} + +func (m *Manager) reloadNginx() error { + reloadCmd := m.config.Nginx.ReloadCommand + if reloadCmd == "" { + reloadCmd = "nginx -s reload" + } + + cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", reloadCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w", string(output), err) + } + return nil +} + +func (m *Manager) getNginxDir() string { + configPath := m.config.Nginx.ConfigPath + if configPath == "" { + return filepath.Join(m.config.DeploymentsPath, "nginx") + } + return filepath.Dir(configPath) +} diff --git a/internal/infra/manager_test.go b/internal/infra/manager_test.go new file mode 100644 index 0000000..2d0180f --- /dev/null +++ b/internal/infra/manager_test.go @@ -0,0 +1,190 @@ +package infra + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flatrun/agent/pkg/config" +) + +func TestSetNginxRealtimeCapture(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "infra-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + nginxDir := filepath.Join(tmpDir, "nginx") + confDir := filepath.Join(nginxDir, "conf.d") + + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Nginx: config.NginxConfig{ + ConfigPath: confDir, + }, + } + + m := NewManager(cfg) + + t.Run("enable realtime capture creates lua config and files", func(t *testing.T) { + err := m.SetNginxRealtimeCapture(true) + if err != nil { + t.Fatalf("SetNginxRealtimeCapture(true) failed: %v", err) + } + + nginxConfPath := filepath.Join(nginxDir, "nginx.conf") + content, err := os.ReadFile(nginxConfPath) + if err != nil { + t.Fatalf("failed to read nginx.conf: %v", err) + } + + if !strings.Contains(string(content), "lua_package_path") { + t.Error("nginx.conf should contain lua_package_path when realtime capture is enabled") + } + if !strings.Contains(string(content), "init_by_lua_block") { + t.Error("nginx.conf should contain init_by_lua_block when realtime capture is enabled") + } + + luaPath := filepath.Join(nginxDir, "lua", "security.lua") + if _, err := os.Stat(luaPath); os.IsNotExist(err) { + t.Error("security.lua should be created when realtime capture is enabled") + } + + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + t.Error("blocked_ips.conf should be created") + } + + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + t.Error("rate_limits.conf should be created") + } + }) + + t.Run("disable realtime capture removes lua directives from config", func(t *testing.T) { + err := m.SetNginxRealtimeCapture(false) + if err != nil { + t.Fatalf("SetNginxRealtimeCapture(false) failed: %v", err) + } + + nginxConfPath := filepath.Join(nginxDir, "nginx.conf") + content, err := os.ReadFile(nginxConfPath) + if err != nil { + t.Fatalf("failed to read nginx.conf: %v", err) + } + + if strings.Contains(string(content), "lua_package_path") { + t.Error("nginx.conf should NOT contain lua_package_path when realtime capture is disabled") + } + if strings.Contains(string(content), "init_by_lua_block") { + t.Error("nginx.conf should NOT contain init_by_lua_block when realtime capture is disabled") + } + + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + t.Error("blocked_ips.conf should still exist after disabling realtime capture") + } + + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + t.Error("rate_limits.conf should still exist after disabling realtime capture") + } + }) + + t.Run("basic config still has security headers", func(t *testing.T) { + err := m.SetNginxRealtimeCapture(false) + if err != nil { + t.Fatalf("SetNginxRealtimeCapture(false) failed: %v", err) + } + + nginxConfPath := filepath.Join(nginxDir, "nginx.conf") + content, err := os.ReadFile(nginxConfPath) + if err != nil { + t.Fatalf("failed to read nginx.conf: %v", err) + } + + if !strings.Contains(string(content), "X-Frame-Options") { + t.Error("basic nginx.conf should contain X-Frame-Options header") + } + if !strings.Contains(string(content), "X-Content-Type-Options") { + t.Error("basic nginx.conf should contain X-Content-Type-Options header") + } + }) + + t.Run("switching between configs preserves vhost configs", func(t *testing.T) { + if err := os.MkdirAll(confDir, 0755); err != nil { + t.Fatalf("failed to create conf.d: %v", err) + } + + vhostContent := "server { listen 80; server_name test.example.com; }" + vhostPath := filepath.Join(confDir, "test-app.conf") + if err := os.WriteFile(vhostPath, []byte(vhostContent), 0644); err != nil { + t.Fatalf("failed to write vhost config: %v", err) + } + + if err := m.SetNginxRealtimeCapture(true); err != nil { + t.Fatalf("SetNginxRealtimeCapture(true) failed: %v", err) + } + + content, err := os.ReadFile(vhostPath) + if err != nil { + t.Fatalf("vhost config should still exist after enabling realtime capture: %v", err) + } + if string(content) != vhostContent { + t.Error("vhost config content should be preserved") + } + + if err := m.SetNginxRealtimeCapture(false); err != nil { + t.Fatalf("SetNginxRealtimeCapture(false) failed: %v", err) + } + + content, err = os.ReadFile(vhostPath) + if err != nil { + t.Fatalf("vhost config should still exist after disabling realtime capture: %v", err) + } + if string(content) != vhostContent { + t.Error("vhost config content should be preserved") + } + }) +} + +func TestGetNginxDir(t *testing.T) { + tests := []struct { + name string + configPath string + deployments string + expected string + }{ + { + name: "uses parent of config_path", + configPath: "/deployments/nginx/conf.d", + deployments: "/deployments", + expected: "/deployments/nginx", + }, + { + name: "falls back to deployments/nginx when config_path empty", + configPath: "", + deployments: "/var/flatrun", + expected: "/var/flatrun/nginx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: tt.deployments, + Nginx: config.NginxConfig{ + ConfigPath: tt.configPath, + }, + } + m := NewManager(cfg) + + result := m.getNginxDir() + if result != tt.expected { + t.Errorf("getNginxDir() = %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/internal/security/db.go b/internal/security/db.go index 8817da6..b7a0c6c 100644 --- a/internal/security/db.go +++ b/internal/security/db.go @@ -447,6 +447,9 @@ func (db *DB) GetStats() (*SecurityStats, error) { // Last 24 hours _ = db.conn.QueryRow("SELECT COUNT(*) FROM security_events WHERE created_at >= datetime('now', '-24 hours')").Scan(&stats.Last24Hours) + // Last 7 days + _ = db.conn.QueryRow("SELECT COUNT(*) FROM security_events WHERE created_at >= datetime('now', '-7 days')").Scan(&stats.Last7Days) + // Blocked IPs count _ = db.conn.QueryRow("SELECT COUNT(*) FROM blocked_ips WHERE expires_at IS NULL OR expires_at > datetime('now')").Scan(&stats.BlockedIPsCount) @@ -496,6 +499,43 @@ func (db *DB) GetStats() (*SecurityStats, error) { } } + // Top deployments by events + rows, err = db.conn.Query(` + SELECT deployment_name, COUNT(*) as cnt, + SUM(CASE WHEN severity = 'critical' THEN 1 ELSE 0 END) as critical, + SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END) as high + FROM security_events + WHERE deployment_name IS NOT NULL AND deployment_name != '' + GROUP BY deployment_name + ORDER BY cnt DESC + LIMIT 10`) + if err == nil { + defer rows.Close() + for rows.Next() { + var d DeploymentStats + if err := rows.Scan(&d.Name, &d.EventCount, &d.Critical, &d.High); err == nil { + stats.TopDeployments = append(stats.TopDeployments, d) + } + } + } + + // Events trend (last 7 days) + rows, err = db.conn.Query(` + SELECT date(created_at) as dt, COUNT(*) as cnt + FROM security_events + WHERE created_at >= datetime('now', '-7 days') + GROUP BY dt + ORDER BY dt ASC`) + if err == nil { + defer rows.Close() + for rows.Next() { + var t TrendPoint + if err := rows.Scan(&t.Date, &t.Count); err == nil { + stats.EventsTrend = append(stats.EventsTrend, t) + } + } + } + // Recent critical events rows, err = db.conn.Query(` SELECT id, event_type, severity, source_ip, request_path, message, created_at diff --git a/internal/security/manager.go b/internal/security/manager.go index 7f0cdf0..6491742 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -7,9 +7,10 @@ import ( ) type Manager struct { - db *DB - detector *Detector - mu sync.RWMutex + db *DB + detector *Detector + deploymentsPath string + mu sync.RWMutex } func NewManager(deploymentsPath string) (*Manager, error) { @@ -19,11 +20,19 @@ func NewManager(deploymentsPath string) (*Manager, error) { } return &Manager{ - db: db, - detector: NewDetector(), + db: db, + detector: NewDetector(), + deploymentsPath: deploymentsPath, }, nil } +// InitNginxConfigs ensures the nginx security config files exist. +// This should be called after manager initialization with the nginx config path. +func (m *Manager) InitNginxConfigs(nginxConfigPath string) error { + generator := NewNginxConfigGenerator(m, nginxConfigPath) + return generator.EnsureSecurityConfigFiles() +} + func (m *Manager) Close() error { return m.db.Close() } diff --git a/internal/security/models.go b/internal/security/models.go index 40eb1bb..31dc6b4 100644 --- a/internal/security/models.go +++ b/internal/security/models.go @@ -47,14 +47,17 @@ type ProtectedRoute struct { } type SecurityStats struct { - TotalEvents int `json:"total_events"` - Last24Hours int `json:"last_24_hours"` - BlockedIPsCount int `json:"blocked_ips_count"` - ProtectedRoutesCount int `json:"protected_routes_count"` - BySeverity map[string]int `json:"by_severity"` - ByType map[string]int `json:"by_type"` - TopOffendingIPs []IPStats `json:"top_offending_ips"` - RecentCritical []SecurityEvent `json:"recent_critical"` + TotalEvents int `json:"total_events"` + Last24Hours int `json:"last_24_hours"` + Last7Days int `json:"last_7_days"` + BlockedIPsCount int `json:"blocked_ips_count"` + ProtectedRoutesCount int `json:"protected_routes_count"` + BySeverity map[string]int `json:"by_severity"` + ByType map[string]int `json:"by_type"` + TopOffendingIPs []IPStats `json:"top_offending_ips"` + TopDeployments []DeploymentStats `json:"top_deployments"` + RecentCritical []SecurityEvent `json:"recent_critical"` + EventsTrend []TrendPoint `json:"events_trend"` } type IPStats struct { @@ -63,6 +66,18 @@ type IPStats struct { LastSeen time.Time `json:"last_seen"` } +type DeploymentStats struct { + Name string `json:"name"` + EventCount int `json:"event_count"` + Critical int `json:"critical"` + High int `json:"high"` +} + +type TrendPoint struct { + Date string `json:"date"` + Count int `json:"count"` +} + // Event types const ( EventTypeUnauthorizedAccess = "unauthorized_access" diff --git a/internal/security/nginx.go b/internal/security/nginx.go index a75810b..1b6e562 100644 --- a/internal/security/nginx.go +++ b/internal/security/nginx.go @@ -24,6 +24,33 @@ func NewNginxConfigGenerator(manager *Manager, configPath string) *NginxConfigGe } } +// EnsureSecurityConfigFiles creates the blocked_ips.conf and rate_limits.conf files +// if they don't exist. This is called during initialization to ensure nginx can start. +func (g *NginxConfigGenerator) EnsureSecurityConfigFiles() error { + blockedIPsPath := filepath.Join(g.configPath, "blocked_ips.conf") + rateLimitsPath := filepath.Join(g.configPath, "rate_limits.conf") + + if err := os.MkdirAll(g.configPath, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + content := "# Auto-generated by FlatRun Security\n# No blocked IPs\n" + if err := os.WriteFile(blockedIPsPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to create blocked_ips.conf: %w", err) + } + } + + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + content := "# Auto-generated by FlatRun Security\n# No rate limit zones defined\n" + if err := os.WriteFile(rateLimitsPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to create rate_limits.conf: %w", err) + } + } + + return nil +} + // GenerateBlockedIPsConfig generates the blocked_ips.conf file func (g *NginxConfigGenerator) GenerateBlockedIPsConfig() (string, error) { blockedIPs, err := g.manager.GetActiveBlockedIPs() diff --git a/internal/security/nginx_test.go b/internal/security/nginx_test.go new file mode 100644 index 0000000..bb3a4eb --- /dev/null +++ b/internal/security/nginx_test.go @@ -0,0 +1,157 @@ +package security + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureSecurityConfigFiles(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "security-nginx-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + confDir := filepath.Join(tmpDir, "nginx", "conf.d") + + generator := &NginxConfigGenerator{ + configPath: confDir, + } + + t.Run("creates config files in non-existent directory", func(t *testing.T) { + err := generator.EnsureSecurityConfigFiles() + if err != nil { + t.Fatalf("EnsureSecurityConfigFiles failed: %v", err) + } + + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + t.Error("blocked_ips.conf should be created") + } + + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + t.Error("rate_limits.conf should be created") + } + }) + + t.Run("blocked_ips.conf has valid nginx content", func(t *testing.T) { + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + content, err := os.ReadFile(blockedIPsPath) + if err != nil { + t.Fatalf("failed to read blocked_ips.conf: %v", err) + } + + if !strings.Contains(string(content), "# Auto-generated") { + t.Error("blocked_ips.conf should contain auto-generated comment") + } + }) + + t.Run("rate_limits.conf has valid nginx content", func(t *testing.T) { + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + content, err := os.ReadFile(rateLimitsPath) + if err != nil { + t.Fatalf("failed to read rate_limits.conf: %v", err) + } + + if !strings.Contains(string(content), "# Auto-generated") { + t.Error("rate_limits.conf should contain auto-generated comment") + } + }) + + t.Run("does not overwrite existing files", func(t *testing.T) { + blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") + customContent := "# Custom blocked IPs\ndeny 1.2.3.4;\n" + if err := os.WriteFile(blockedIPsPath, []byte(customContent), 0644); err != nil { + t.Fatalf("failed to write custom content: %v", err) + } + + err := generator.EnsureSecurityConfigFiles() + if err != nil { + t.Fatalf("EnsureSecurityConfigFiles failed: %v", err) + } + + content, err := os.ReadFile(blockedIPsPath) + if err != nil { + t.Fatalf("failed to read blocked_ips.conf: %v", err) + } + + if string(content) != customContent { + t.Error("EnsureSecurityConfigFiles should not overwrite existing files") + } + }) +} + +func TestGenerateProtectedPathsConfig(t *testing.T) { + generator := &NginxConfigGenerator{} + + t.Run("empty paths returns empty string", func(t *testing.T) { + result := generator.GenerateProtectedPathsConfig(nil) + if result != "" { + t.Errorf("expected empty string for nil paths, got %q", result) + } + + result = generator.GenerateProtectedPathsConfig([]string{}) + if result != "" { + t.Errorf("expected empty string for empty paths, got %q", result) + } + }) + + t.Run("generates location blocks for paths", func(t *testing.T) { + paths := []string{".env", ".git"} + result := generator.GenerateProtectedPathsConfig(paths) + + if !strings.Contains(result, "location ~") { + t.Error("should contain location directives") + } + if !strings.Contains(result, "return 404") { + t.Error("should return 404 for protected paths") + } + }) +} + +func TestGenerateZoneName(t *testing.T) { + tests := []struct { + pattern string + expected string + }{ + {"/wp-login.php", "wp-login_php"}, + {"/admin", "admin"}, + {"/api/v1", "api_v1"}, + {"~.*\\.env", "env"}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + result := generateZoneName(tt.pattern) + if result == "" { + t.Errorf("generateZoneName(%q) returned empty string", tt.pattern) + } + if len(result) > 20 && !strings.HasPrefix(result, "zone_") { + t.Errorf("long zone names should be hashed with zone_ prefix") + } + }) + } +} + +func TestEscapeNginxRegex(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {".env", "\\.env"}, + {".git/*", "\\.git/.*"}, + {"wp-config.php", "wp-config\\.php"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeNginxRegex(tt.input) + if result != tt.expected { + t.Errorf("escapeNginxRegex(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a297e5b..12f8688 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -112,6 +112,7 @@ type SharedRedisConfig struct { type SecurityConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` + RealtimeCapture bool `yaml:"realtime_capture" json:"realtime_capture"` ScanInterval time.Duration `yaml:"scan_interval" json:"scan_interval"` RetentionDays int `yaml:"retention_days" json:"retention_days"` RateThreshold int `yaml:"rate_threshold" json:"rate_threshold"` diff --git a/templates/infra/nginx/nginx.conf b/templates/infra/nginx/nginx.conf index db4c530..37c7c0f 100644 --- a/templates/infra/nginx/nginx.conf +++ b/templates/infra/nginx/nginx.conf @@ -1,20 +1,20 @@ worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /run/nginx.pid; +error_log stderr warn; +pid /tmp/nginx.pid; events { worker_connections 1024; } http { - include /etc/nginx/mime.types; + include mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; + access_log /dev/stdout main; sendfile on; tcp_nopush on; @@ -35,25 +35,13 @@ http { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - # Lua package path - lua_package_path "/etc/nginx/lua/?.lua;;"; - - # Shared dictionary for security events - lua_shared_dict security_events 10m; - lua_shared_dict ip_rate_limit 10m; - - # Load security module - init_by_lua_block { - security = require "security" - } - # Docker DNS resolver resolver 127.0.0.11 valid=30s; - # Include blocked IPs (if exists) + # Include blocked IPs include /etc/nginx/conf.d/blocked_ips.conf; - # Include rate limit zones (if exists) + # Include rate limit zones include /etc/nginx/conf.d/rate_limits.conf; # Include virtual hosts diff --git a/templates/infra/nginx/nginx.lua.conf b/templates/infra/nginx/nginx.lua.conf new file mode 100644 index 0000000..caf5eab --- /dev/null +++ b/templates/infra/nginx/nginx.lua.conf @@ -0,0 +1,61 @@ +worker_processes auto; +error_log stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Lua package path + lua_package_path "/etc/nginx/lua/?.lua;;"; + + # Shared dictionary for security events + lua_shared_dict security_events 10m; + lua_shared_dict ip_rate_limit 10m; + + # Load security module + init_by_lua_block { + security = require "security" + } + + # Docker DNS resolver + resolver 127.0.0.11 valid=30s; + + # Include blocked IPs (if exists) + include /etc/nginx/conf.d/blocked_ips.conf; + + # Include rate limit zones (if exists) + include /etc/nginx/conf.d/rate_limits.conf; + + # Include virtual hosts + include /etc/nginx/conf.d/*.conf; +} diff --git a/templates/templates.go b/templates/templates.go index dc8fff8..6159c7b 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -8,6 +8,7 @@ import ( //go:embed */metadata.yml */docker-compose.yml //go:embed infra/*/metadata.yml infra/*/docker-compose.yml +//go:embed infra/nginx/nginx.conf infra/nginx/nginx.lua.conf infra/nginx/lua/* var FS embed.FS var Categories = []Category{ @@ -75,3 +76,14 @@ func GetCompose(name string) ([]byte, error) { func GetCategories() []Category { return Categories } + +func GetNginxConfig(luaEnabled bool) ([]byte, error) { + if luaEnabled { + return FS.ReadFile("infra/nginx/nginx.lua.conf") + } + return FS.ReadFile("infra/nginx/nginx.conf") +} + +func GetNginxSecurityLua() ([]byte, error) { + return FS.ReadFile("infra/nginx/lua/security.lua") +} diff --git a/test/e2e/Dockerfile.lua b/test/e2e/Dockerfile.lua new file mode 100644 index 0000000..763c8c6 --- /dev/null +++ b/test/e2e/Dockerfile.lua @@ -0,0 +1,27 @@ +FROM golang:1.21-alpine AS builder + +RUN apk add --no-cache git gcc musl-dev + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=1 go build -o flatrun-agent ./cmd/agent + +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates docker-cli docker-cli-compose wget curl + +WORKDIR /app + +COPY --from=builder /app/flatrun-agent . +COPY test/e2e/config.lua.yml /app/config.yml + +RUN mkdir -p /deployments + +EXPOSE 8090 + +CMD ["./flatrun-agent", "-config", "/app/config.yml"] diff --git a/test/e2e/Dockerfile.security b/test/e2e/Dockerfile.security new file mode 100644 index 0000000..a8a7b2c --- /dev/null +++ b/test/e2e/Dockerfile.security @@ -0,0 +1,27 @@ +FROM golang:1.21-alpine AS builder + +RUN apk add --no-cache git gcc musl-dev + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=1 go build -o flatrun-agent ./cmd/agent + +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates docker-cli docker-cli-compose wget + +WORKDIR /app + +COPY --from=builder /app/flatrun-agent . +COPY test/e2e/config.security.yml /app/config.yml + +RUN mkdir -p /deployments + +EXPOSE 8090 + +CMD ["./flatrun-agent", "-config", "/app/config.yml"] diff --git a/test/e2e/config.lua.yml b/test/e2e/config.lua.yml new file mode 100644 index 0000000..e3bb1ef --- /dev/null +++ b/test/e2e/config.lua.yml @@ -0,0 +1,36 @@ +deployments_path: /tmp/flatrun-e2e-lua +docker_socket: unix:///var/run/docker.sock + +infrastructure: + default_proxy_network: proxy + default_database_network: database + +api: + host: 0.0.0.0 + port: 8090 + enable_cors: true + allowed_origins: + - "*" + +auth: + enabled: false + +nginx: + container_name: flatrun-e2e-lua-openresty + config_path: /tmp/flatrun-e2e-lua/nginx/conf.d + reload_command: nginx -s reload + +security: + enabled: true + realtime_capture: true + scan_interval: 10s + retention_days: 7 + rate_threshold: 100 + +logging: + level: debug + format: text + +health: + check_interval: 30s + metrics_retention: 24h diff --git a/test/e2e/config.security.yml b/test/e2e/config.security.yml new file mode 100644 index 0000000..6e9e048 --- /dev/null +++ b/test/e2e/config.security.yml @@ -0,0 +1,35 @@ +deployments_path: /tmp/flatrun-e2e-security +docker_socket: unix:///var/run/docker.sock + +infrastructure: + default_proxy_network: proxy + default_database_network: database + +api: + host: 0.0.0.0 + port: 8090 + enable_cors: true + allowed_origins: + - "*" + +auth: + enabled: false + +nginx: + container_name: flatrun-e2e-security-nginx + config_path: /tmp/flatrun-e2e-security/nginx/conf.d + reload_command: nginx -s reload + +security: + enabled: true + scan_interval: 10s + retention_days: 7 + rate_threshold: 100 + +logging: + level: debug + format: text + +health: + check_interval: 30s + metrics_retention: 24h diff --git a/test/e2e/docker-compose.lua.yml b/test/e2e/docker-compose.lua.yml new file mode 100644 index 0000000..37d7d1e --- /dev/null +++ b/test/e2e/docker-compose.lua.yml @@ -0,0 +1,48 @@ +name: flatrun-e2e-lua + +services: + agent: + build: + context: ../.. + dockerfile: test/e2e/Dockerfile.lua + container_name: flatrun-e2e-lua-agent + ports: + - "18092:8090" + volumes: + - /tmp/flatrun-e2e-lua:/tmp/flatrun-e2e-lua + - /var/run/docker.sock:/var/run/docker.sock + networks: + - default + - proxy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8090/api/health"] + interval: 2s + timeout: 5s + retries: 15 + + openresty: + image: ghcr.io/flatrun/openresty:latest + container_name: flatrun-e2e-lua-openresty + ports: + - "18082:80" + environment: + - FLATRUN_AGENT_URL=http://flatrun-e2e-lua-agent:8090 + volumes: + - ./nginx/lua/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro + - ./nginx/lua/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/lua/security.lua:/etc/nginx/lua/security.lua:ro + - /tmp/flatrun-e2e-lua/nginx/conf.d:/tmp/flatrun-e2e-lua/nginx/conf.d:ro + networks: + - proxy + depends_on: + agent: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1/health"] + interval: 2s + timeout: 5s + retries: 10 + +networks: + proxy: + external: true diff --git a/test/e2e/docker-compose.security.yml b/test/e2e/docker-compose.security.yml new file mode 100644 index 0000000..bb3d263 --- /dev/null +++ b/test/e2e/docker-compose.security.yml @@ -0,0 +1,45 @@ +name: flatrun-e2e-security + +services: + agent: + build: + context: ../.. + dockerfile: test/e2e/Dockerfile.security + container_name: flatrun-e2e-security-agent + ports: + - "18091:8090" + volumes: + - /tmp/flatrun-e2e-security:/tmp/flatrun-e2e-security + - /var/run/docker.sock:/var/run/docker.sock + networks: + - default + - proxy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8090/api/health"] + interval: 2s + timeout: 5s + retries: 15 + + nginx: + image: nginx:alpine + container_name: flatrun-e2e-security-nginx + ports: + - "18081:80" + volumes: + - ./nginx/security/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/security/default.conf:/etc/nginx/conf.d/default.conf:ro + - /tmp/flatrun-e2e-security/nginx/conf.d:/tmp/flatrun-e2e-security/nginx/conf.d:ro + networks: + - proxy + depends_on: + agent: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/health"] + interval: 2s + timeout: 5s + retries: 10 + +networks: + proxy: + external: true diff --git a/test/e2e/lua_test.go b/test/e2e/lua_test.go new file mode 100644 index 0000000..0a4d18e --- /dev/null +++ b/test/e2e/lua_test.go @@ -0,0 +1,360 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const ( + luaDeploymentsPath = "/tmp/flatrun-e2e-lua" + luaAPIPort = "18092" + luaNginxPort = "18082" +) + +func TestLuaRealtimeCapture(t *testing.T) { + if os.Getenv("FLATRUN_LUA_TEST") != "true" { + t.Skip("Skipping Lua test - set FLATRUN_LUA_TEST=true to run") + } + + if err := setupLuaTestEnvironment(); err != nil { + t.Fatalf("Failed to setup Lua test environment: %v", err) + } + defer cleanupLuaTestEnvironment() + + if err := waitForLuaAgent(); err != nil { + t.Fatalf("Lua agent failed to start: %v", err) + } + + // Clean up any existing events and blocked IPs from previous runs + cleanupSecurityState(t) + + t.Run("security config files created", func(t *testing.T) { + blockedIPsPath := filepath.Join(luaDeploymentsPath, "nginx", "conf.d", "blocked_ips.conf") + if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { + t.Fatalf("blocked_ips.conf should exist") + } + + rateLimitsPath := filepath.Join(luaDeploymentsPath, "nginx", "conf.d", "rate_limits.conf") + if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { + t.Fatalf("rate_limits.conf should exist") + } + }) + + t.Run("openresty is healthy", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/health", luaNginxPort)) + if err != nil { + t.Fatalf("OpenResty health check failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("OpenResty health check returned %d, expected 200", resp.StatusCode) + } + }) + + t.Run("403 triggers security event", func(t *testing.T) { + initialCount := getEventCount(t) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/admin", luaNginxPort), nil) + req.Header.Set("User-Agent", "Mozilla/5.0 FlatRunTest") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request to /admin failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected 403, got %d", resp.StatusCode) + } + + time.Sleep(2 * time.Second) + + newCount := getEventCount(t) + if newCount <= initialCount { + t.Errorf("Expected event count to increase, was %d, now %d", initialCount, newCount) + } + t.Logf("Event count increased from %d to %d", initialCount, newCount) + }) + + t.Run("401 triggers security event", func(t *testing.T) { + initialCount := getEventCount(t) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/api/private", luaNginxPort), nil) + req.Header.Set("User-Agent", "Mozilla/5.0 FlatRunTest") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request to /api/private failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", resp.StatusCode) + } + + time.Sleep(2 * time.Second) + + newCount := getEventCount(t) + if newCount <= initialCount { + t.Errorf("Expected event count to increase, was %d, now %d", initialCount, newCount) + } + t.Logf("Event count increased from %d to %d", initialCount, newCount) + }) + + t.Run("500 triggers security event", func(t *testing.T) { + initialCount := getEventCount(t) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/error", luaNginxPort), nil) + req.Header.Set("User-Agent", "Mozilla/5.0 FlatRunTest") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request to /error failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("Expected 500, got %d", resp.StatusCode) + } + + time.Sleep(2 * time.Second) + + newCount := getEventCount(t) + if newCount <= initialCount { + t.Errorf("Expected event count to increase, was %d, now %d", initialCount, newCount) + } + t.Logf("Event count increased from %d to %d", initialCount, newCount) + }) + + t.Run("suspicious path triggers security event", func(t *testing.T) { + initialCount := getEventCount(t) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/.env", luaNginxPort), nil) + req.Header.Set("User-Agent", "Mozilla/5.0 FlatRunTest") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request to /.env failed: %v", err) + } + resp.Body.Close() + + time.Sleep(2 * time.Second) + + newCount := getEventCount(t) + if newCount <= initialCount { + t.Errorf("Expected event count to increase for suspicious path, was %d, now %d", initialCount, newCount) + } + t.Logf("Event count increased from %d to %d", initialCount, newCount) + }) + + t.Run("scanner user agent triggers security event", func(t *testing.T) { + initialCount := getEventCount(t) + + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/", luaNginxPort), nil) + req.Header.Set("User-Agent", "nikto/2.1.6") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request with scanner UA failed: %v", err) + } + resp.Body.Close() + + time.Sleep(2 * time.Second) + + newCount := getEventCount(t) + if newCount <= initialCount { + t.Errorf("Expected event count to increase for scanner UA, was %d, now %d", initialCount, newCount) + } + t.Logf("Event count increased from %d to %d", initialCount, newCount) + }) + + t.Run("events have correct data", func(t *testing.T) { + events := getEvents(t) + if len(events) == 0 { + t.Fatal("Expected at least one event") + } + + for _, event := range events { + if event.SourceIP == "" { + t.Error("Event missing source_ip") + } + if event.RequestPath == "" { + t.Error("Event missing request_path") + } + if event.StatusCode == 0 { + t.Error("Event missing status_code") + } + if event.EventType == "" { + t.Error("Event missing event_type") + } + if event.Severity == "" { + t.Error("Event missing severity") + } + } + t.Logf("Verified %d events have correct data", len(events)) + }) + + t.Run("stats endpoint reflects captured events", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/stats", luaAPIPort)) + if err != nil { + t.Fatalf("Stats request failed: %v", err) + } + defer resp.Body.Close() + + var result struct { + Stats struct { + TotalEvents int `json:"total_events"` + BySeverity map[string]int `json:"by_severity"` + ByType map[string]int `json:"by_type"` + } `json:"stats"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode stats: %v", err) + } + + if result.Stats.TotalEvents == 0 { + t.Error("Expected total_events > 0") + } + t.Logf("Stats: total=%d, by_severity=%v, by_type=%v", + result.Stats.TotalEvents, result.Stats.BySeverity, result.Stats.ByType) + }) +} + +type SecurityEvent struct { + ID int `json:"id"` + SourceIP string `json:"source_ip"` + RequestPath string `json:"request_path"` + StatusCode int `json:"status_code"` + EventType string `json:"event_type"` + Severity string `json:"severity"` +} + +func getEventCount(t *testing.T) int { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/stats", luaAPIPort)) + if err != nil { + t.Fatalf("Failed to get stats: %v", err) + } + defer resp.Body.Close() + + var result struct { + Stats struct { + TotalEvents int `json:"total_events"` + } `json:"stats"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode stats: %v", err) + } + + return result.Stats.TotalEvents +} + +func getEvents(t *testing.T) []SecurityEvent { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/events?limit=100", luaAPIPort)) + if err != nil { + t.Fatalf("Failed to get events: %v", err) + } + defer resp.Body.Close() + + var result struct { + Events []SecurityEvent `json:"events"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode events: %v", err) + } + + return result.Events +} + +func cleanupSecurityState(t *testing.T) { + // Get all blocked IPs + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/blocked-ips", luaAPIPort)) + if err != nil { + t.Logf("Warning: Could not get blocked IPs: %v", err) + return + } + defer resp.Body.Close() + + var result struct { + BlockedIPs []struct { + IP string `json:"ip"` + } `json:"blocked_ips"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Logf("Warning: Could not decode blocked IPs: %v", err) + return + } + + // Unblock each IP + client := &http.Client{} + for _, blocked := range result.BlockedIPs { + req, _ := http.NewRequest("DELETE", fmt.Sprintf("http://localhost:%s/api/security/blocked-ips/%s", luaAPIPort, blocked.IP), nil) + resp, err := client.Do(req) + if err != nil { + t.Logf("Warning: Could not unblock IP %s: %v", blocked.IP, err) + continue + } + resp.Body.Close() + } + + t.Logf("Cleaned up %d blocked IPs", len(result.BlockedIPs)) +} + +func setupLuaTestEnvironment() error { + _ = os.RemoveAll(luaDeploymentsPath) + + confDir := filepath.Join(luaDeploymentsPath, "nginx", "conf.d") + if err := os.MkdirAll(confDir, 0755); err != nil { + return fmt.Errorf("failed to create conf.d directory: %w", err) + } + + cmd := exec.Command("docker", "compose", "-f", "docker-compose.lua.yml", "up", "-d", "--build") + cmd.Dir = getTestDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func cleanupLuaTestEnvironment() { + cmd := exec.Command("docker", "compose", "-f", "docker-compose.lua.yml", "down", "-v", "--remove-orphans") + cmd.Dir = getTestDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + + _ = os.RemoveAll(luaDeploymentsPath) +} + +func waitForLuaAgent() error { + deadline := time.Now().Add(120 * time.Second) + + for time.Now().Before(deadline) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/health", luaAPIPort)) + if err == nil && resp.StatusCode == 200 { + resp.Body.Close() + time.Sleep(2 * time.Second) + return nil + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(2 * time.Second) + } + + return fmt.Errorf("timeout waiting for Lua agent") +} diff --git a/test/e2e/nginx/lua/default.conf b/test/e2e/nginx/lua/default.conf new file mode 100644 index 0000000..8331def --- /dev/null +++ b/test/e2e/nginx/lua/default.conf @@ -0,0 +1,43 @@ +server { + listen 80 default_server; + server_name _; + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location / { + # Capture security events via Lua + log_by_lua_block { + security.capture_event() + } + + return 404; + } + + # Test endpoint that returns 403 + location /admin { + log_by_lua_block { + security.capture_event() + } + return 403; + } + + # Test endpoint that returns 401 + location /api/private { + log_by_lua_block { + security.capture_event() + } + return 401; + } + + # Test endpoint that returns 500 + location /error { + log_by_lua_block { + security.capture_event() + } + return 500; + } +} diff --git a/test/e2e/nginx/lua/nginx.conf b/test/e2e/nginx/lua/nginx.conf new file mode 100644 index 0000000..3c3b789 --- /dev/null +++ b/test/e2e/nginx/lua/nginx.conf @@ -0,0 +1,50 @@ +worker_processes auto; +error_log stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + + # Lua package path + lua_package_path "/etc/nginx/lua/?.lua;;"; + + # Shared dictionary for security events + lua_shared_dict security_events 10m; + lua_shared_dict ip_rate_limit 10m; + + # Load security module + init_by_lua_block { + security = require "security" + } + + # Docker DNS resolver + resolver 127.0.0.11 valid=10s ipv6=off; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Include blocked IPs (if exists) + include /tmp/flatrun-e2e-lua/nginx/conf.d/blocked_ips.conf; + + # Include rate limit zones (if exists) + include /tmp/flatrun-e2e-lua/nginx/conf.d/rate_limits.conf; + + # Default configs + include /etc/nginx/conf.d/*.conf; +} diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua new file mode 100644 index 0000000..cb106af --- /dev/null +++ b/test/e2e/nginx/lua/security.lua @@ -0,0 +1,180 @@ +-- FlatRun Security Event Capture +-- This script captures security-relevant events and sends them to the agent API + +local cjson = require "cjson.safe" +local http = require "resty.http" + +local _M = {} + +-- Configuration (will be set by the agent) +local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" + +-- Suspicious paths patterns +local suspicious_patterns = { + "%.env", + "%.git", + "wp%-admin", + "wp%-login", + "wp%-config", + "xmlrpc%.php", + "phpmyadmin", + "adminer", + "/admin", + "/administrator", + "/shell", + "/cmd", + "/backdoor", + "%.sql", + "%.bak", + "%.backup", + "%.old", + "/actuator", + "/swagger", + "/api%-docs", + "%.aws", + "%.ssh", + "%.docker", + "/debug", + "/trace", + "composer%.json", + "package%.json", +} + +-- Scanner user agent patterns +local scanner_patterns = { + "nikto", + "nmap", + "sqlmap", + "dirbuster", + "gobuster", + "nuclei", + "masscan", + "wpscan", + "burp", + "acunetix", + "nessus", + "zgrab", +} + +function _M.is_suspicious_path(uri) + if not uri then return false end + local uri_lower = string.lower(uri) + for _, pattern in ipairs(suspicious_patterns) do + if string.find(uri_lower, pattern) then + return true + end + end + return false +end + +function _M.is_scanner(user_agent) + if not user_agent then return false end + local ua_lower = string.lower(user_agent) + for _, pattern in ipairs(scanner_patterns) do + if string.find(ua_lower, pattern) then + return true + end + end + return false +end + +function _M.capture_event() + local status = ngx.status + local uri = ngx.var.uri + local ip = ngx.var.remote_addr + local method = ngx.var.request_method + local user_agent = ngx.var.http_user_agent or "" + local host = ngx.var.host or "" + + -- Only capture security-relevant events + local should_capture = false + + -- Scanner detection + if _M.is_scanner(user_agent) then + should_capture = true + -- Rate limit hit + elseif status == 429 then + should_capture = true + -- Auth failures + elseif status == 401 or status == 403 then + should_capture = true + -- Server errors + elseif status >= 500 then + should_capture = true + -- 404 on suspicious paths + elseif status == 404 and _M.is_suspicious_path(uri) then + should_capture = true + -- Any non-200 on suspicious paths + elseif _M.is_suspicious_path(uri) and status ~= 200 then + should_capture = true + end + + if not should_capture then + return + end + + -- Extract deployment name from host (remove port if present) + local deployment_name = host:match("^([^:]+)") + + -- Send event to agent API (non-blocking) + local ok, err = ngx.timer.at(0, function(premature) + if premature then return end + + local httpc = http.new() + httpc:set_timeout(5000) + + local body, encode_err = cjson.encode({ + source_ip = ip, + request_path = uri, + request_method = method, + status_code = status, + user_agent = user_agent, + deployment_name = deployment_name, + timestamp = ngx.time() + }) + + if not body then + ngx.log(ngx.ERR, "Failed to encode security event: ", encode_err) + return + end + + local res, req_err = httpc:request_uri(AGENT_URL .. "/api/security/events/ingest", { + method = "POST", + body = body, + headers = { + ["Content-Type"] = "application/json", + } + }) + + if not res then + ngx.log(ngx.ERR, "Failed to send security event: ", req_err) + end + + httpc:close() + end) + + if not ok then + ngx.log(ngx.ERR, "Failed to create timer for security event: ", err) + end +end + +-- Rate limiting helper using shared dict +function _M.check_rate_limit(key, limit, window) + local dict = ngx.shared.ip_rate_limit + if not dict then return false end + + local current = dict:get(key) + if not current then + dict:set(key, 1, window) + return false + end + + if current >= limit then + return true + end + + dict:incr(key, 1) + return false +end + +return _M diff --git a/test/e2e/nginx/security/default.conf b/test/e2e/nginx/security/default.conf new file mode 100644 index 0000000..9b35baf --- /dev/null +++ b/test/e2e/nginx/security/default.conf @@ -0,0 +1,14 @@ +server { + listen 80 default_server; + server_name _; + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location / { + return 404; + } +} diff --git a/test/e2e/nginx/security/nginx.conf b/test/e2e/nginx/security/nginx.conf new file mode 100644 index 0000000..1ef4226 --- /dev/null +++ b/test/e2e/nginx/security/nginx.conf @@ -0,0 +1,42 @@ +user nginx; +worker_processes auto; +error_log stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + + # Docker DNS resolver + resolver 127.0.0.11 valid=10s ipv6=off; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Include blocked IPs (required for security feature) + include /tmp/flatrun-e2e-security/nginx/conf.d/blocked_ips.conf; + + # Include rate limit zones (required for security feature) + include /tmp/flatrun-e2e-security/nginx/conf.d/rate_limits.conf; + + # Default configs + include /etc/nginx/conf.d/*.conf; + + # Dynamic virtual hosts from FlatRun deployments + include /tmp/flatrun-e2e-security/nginx/conf.d/*.conf; +} diff --git a/test/e2e/security_test.go b/test/e2e/security_test.go new file mode 100644 index 0000000..ea84f24 --- /dev/null +++ b/test/e2e/security_test.go @@ -0,0 +1,164 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const ( + securityDeploymentsPath = "/tmp/flatrun-e2e-security" + securityAPIPort = "18091" +) + +func TestSecurityConfigFilesCreated(t *testing.T) { + if os.Getenv("FLATRUN_SECURITY_TEST") != "true" { + t.Skip("Skipping security test - set FLATRUN_SECURITY_TEST=true to run") + } + + // Setup security test environment + if err := setupSecurityTestEnvironment(); err != nil { + t.Fatalf("Failed to setup security test environment: %v", err) + } + defer cleanupSecurityTestEnvironment() + + // Wait for agent to be ready + if err := waitForSecurityAgent(); err != nil { + t.Fatalf("Security agent failed to start: %v", err) + } + + // Test 1: Verify blocked_ips.conf exists and has valid content + t.Run("blocked_ips.conf exists", func(t *testing.T) { + blockedIPsPath := filepath.Join(securityDeploymentsPath, "nginx", "conf.d", "blocked_ips.conf") + content, err := os.ReadFile(blockedIPsPath) + if err != nil { + t.Fatalf("blocked_ips.conf should exist: %v", err) + } + if len(content) == 0 { + t.Error("blocked_ips.conf should not be empty") + } + t.Logf("blocked_ips.conf content:\n%s", string(content)) + }) + + // Test 2: Verify rate_limits.conf exists and has valid content + t.Run("rate_limits.conf exists", func(t *testing.T) { + rateLimitsPath := filepath.Join(securityDeploymentsPath, "nginx", "conf.d", "rate_limits.conf") + content, err := os.ReadFile(rateLimitsPath) + if err != nil { + t.Fatalf("rate_limits.conf should exist: %v", err) + } + if len(content) == 0 { + t.Error("rate_limits.conf should not be empty") + } + t.Logf("rate_limits.conf content:\n%s", string(content)) + }) + + // Test 3: Verify nginx is healthy (means it could parse the config with includes) + t.Run("nginx is healthy with security configs", func(t *testing.T) { + resp, err := http.Get("http://localhost:18081/health") + if err != nil { + t.Fatalf("nginx health check failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("nginx health check returned %d, expected 200", resp.StatusCode) + } + }) + + // Test 4: Verify security stats endpoint works + t.Run("security stats endpoint works", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/stats", securityAPIPort)) + if err != nil { + t.Fatalf("security stats request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("security stats returned %d, expected 200", resp.StatusCode) + } + + var stats map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + t.Errorf("failed to decode security stats: %v", err) + } + t.Logf("Security stats: %+v", stats) + }) + + // Test 5: Verify blocked IPs endpoint works + t.Run("blocked IPs endpoint works", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/blocked-ips", securityAPIPort)) + if err != nil { + t.Fatalf("blocked IPs request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("blocked IPs returned %d, expected 200", resp.StatusCode) + } + }) + + // Test 6: Verify protected routes endpoint works + t.Run("protected routes endpoint works", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/security/protected-routes", securityAPIPort)) + if err != nil { + t.Fatalf("protected routes request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("protected routes returned %d, expected 200", resp.StatusCode) + } + }) +} + +func setupSecurityTestEnvironment() error { + // Clean and create directories + _ = os.RemoveAll(securityDeploymentsPath) + + // Create nginx conf.d directory + confDir := filepath.Join(securityDeploymentsPath, "nginx", "conf.d") + if err := os.MkdirAll(confDir, 0755); err != nil { + return fmt.Errorf("failed to create conf.d directory: %w", err) + } + + // Start security test environment + cmd := exec.Command("docker", "compose", "-f", "docker-compose.security.yml", "up", "-d", "--build") + cmd.Dir = getTestDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func cleanupSecurityTestEnvironment() { + cmd := exec.Command("docker", "compose", "-f", "docker-compose.security.yml", "down", "-v", "--remove-orphans") + cmd.Dir = getTestDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + + _ = os.RemoveAll(securityDeploymentsPath) +} + +func waitForSecurityAgent() error { + deadline := time.Now().Add(120 * time.Second) + + for time.Now().Before(deadline) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/health", securityAPIPort)) + if err == nil && resp.StatusCode == 200 { + resp.Body.Close() + return nil + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(2 * time.Second) + } + + return fmt.Errorf("timeout waiting for security agent") +}