diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index f16b453..8d8de60 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -194,6 +194,19 @@ func (s *Server) listBlockedIPs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"blocked_ips": ips}) } +// listBlockedIPsInternal returns blocked IPs for internal nginx communication +func (s *Server) listBlockedIPsInternal(c *gin.Context) { + token := c.GetHeader("X-Internal-Token") + expectedToken := s.config.Security.InternalAPIToken + + if token == "" || token != expectedToken { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid internal token"}) + return + } + + s.listBlockedIPs(c) +} + // blockIP blocks an IP address func (s *Server) blockIP(c *gin.Context) { if s.securityManager == nil { @@ -611,6 +624,12 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { AutoBlockEnabled *bool `json:"auto_block_enabled"` AutoBlockThreshold int `json:"auto_block_threshold"` AutoBlockDuration string `json:"auto_block_duration"` + // Detection thresholds + DetectionWindow string `json:"detection_window"` + NotFoundThreshold int `json:"not_found_threshold"` + AuthFailureThreshold int `json:"auth_failure_threshold"` + UniquePathsThreshold int `json:"unique_paths_threshold"` + RepeatedHitsThreshold int `json:"repeated_hits_threshold"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -670,6 +689,29 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { updatedFields = append(updatedFields, "auto_block_duration") } } + // Detection thresholds + if req.DetectionWindow != "" { + if d, err := time.ParseDuration(req.DetectionWindow); err == nil { + s.config.Security.DetectionWindow = d + updatedFields = append(updatedFields, "detection_window") + } + } + if req.NotFoundThreshold > 0 { + s.config.Security.NotFoundThreshold = req.NotFoundThreshold + updatedFields = append(updatedFields, "not_found_threshold") + } + if req.AuthFailureThreshold > 0 { + s.config.Security.AuthFailureThreshold = req.AuthFailureThreshold + updatedFields = append(updatedFields, "auth_failure_threshold") + } + if req.UniquePathsThreshold > 0 { + s.config.Security.UniquePathsThreshold = req.UniquePathsThreshold + updatedFields = append(updatedFields, "unique_paths_threshold") + } + if req.RepeatedHitsThreshold > 0 { + s.config.Security.RepeatedHitsThreshold = req.RepeatedHitsThreshold + updatedFields = append(updatedFields, "repeated_hits_threshold") + } result["updated_fields"] = updatedFields @@ -713,16 +755,33 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { // Update dependent managers s.infraManager.UpdateConfig(s.config) + // Update detector thresholds if security manager is available + if s.securityManager != nil { + s.securityManager.SetDetectorThresholds( + s.config.Security.RateThreshold, + s.config.Security.NotFoundThreshold, + s.config.Security.AuthFailureThreshold, + s.config.Security.UniquePathsThreshold, + s.config.Security.RepeatedHitsThreshold, + s.config.Security.DetectionWindow, + ) + } + // Return current security settings result["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(), + "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(), + "detection_window": s.config.Security.DetectionWindow.String(), + "not_found_threshold": s.config.Security.NotFoundThreshold, + "auth_failure_threshold": s.config.Security.AuthFailureThreshold, + "unique_paths_threshold": s.config.Security.UniquePathsThreshold, + "repeated_hits_threshold": s.config.Security.RepeatedHitsThreshold, } c.JSON(http.StatusOK, result) diff --git a/internal/api/server.go b/internal/api/server.go index 0233f2e..8fdc324 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -91,6 +91,15 @@ func New(cfg *config.Config, configPath string) *Server { if err != nil { log.Printf("Warning: Failed to initialize security manager: %v", err) } else { + // Apply detection thresholds from config + securityManager.SetDetectorThresholds( + cfg.Security.RateThreshold, + cfg.Security.NotFoundThreshold, + cfg.Security.AuthFailureThreshold, + cfg.Security.UniquePathsThreshold, + cfg.Security.RepeatedHitsThreshold, + cfg.Security.DetectionWindow, + ) nginxConfigPath := cfg.Nginx.ConfigPath if nginxConfigPath == "" { nginxConfigPath = filepath.Join(cfg.DeploymentsPath, "nginx", "conf.d") @@ -293,6 +302,9 @@ func (s *Server) setupRoutes() { // Ingest endpoints (no auth - called by nginx Lua) api.POST("/security/events/ingest", s.ingestSecurityEvent) api.POST("/traffic/ingest", s.ingestTrafficLog) + + // Internal nginx endpoint - token-authenticated for blocked IPs + api.GET("/_internal/blocked-ips", s.listBlockedIPsInternal) } } diff --git a/internal/infra/manager.go b/internal/infra/manager.go index 72f65ab..f957c54 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -377,7 +377,7 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in result["agent_ip"] = agentIP result["agent_port"] = agentPort - securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort) + securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort, m.config.Security.InternalAPIToken) if err != nil { errors = append(errors, fmt.Sprintf("failed to get security.lua template: %v", err)) } else { @@ -1228,7 +1228,7 @@ func (m *Manager) RefreshSecurityScripts() (*RefreshSecurityScriptsResult, error } // Generate and write security.lua with injected IP - securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort) + securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort, m.config.Security.InternalAPIToken) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("failed to generate security.lua: %v", err)) result.Success = false diff --git a/internal/security/manager.go b/internal/security/manager.go index 05a3105..cae4cab 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -37,6 +37,11 @@ func (m *Manager) Close() error { return m.db.Close() } +// SetDetectorThresholds updates the detector's behavior thresholds +func (m *Manager) SetDetectorThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold int, windowDuration time.Duration) { + m.detector.SetThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold, windowDuration) +} + // IngestResult contains the result of event ingestion type IngestResult struct { Event *SecurityEvent diff --git a/pkg/config/config.go b/pkg/config/config.go index 12f8688..6901a5d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/rand" + "encoding/hex" "os" "time" @@ -119,6 +121,16 @@ type SecurityConfig struct { AutoBlockEnabled bool `yaml:"auto_block_enabled" json:"auto_block_enabled"` AutoBlockThreshold int `yaml:"auto_block_threshold" json:"auto_block_threshold"` AutoBlockDuration time.Duration `yaml:"auto_block_duration" json:"auto_block_duration"` + + // Detection thresholds for autoblock + DetectionWindow time.Duration `yaml:"detection_window" json:"detection_window"` + NotFoundThreshold int `yaml:"not_found_threshold" json:"not_found_threshold"` + AuthFailureThreshold int `yaml:"auth_failure_threshold" json:"auth_failure_threshold"` + UniquePathsThreshold int `yaml:"unique_paths_threshold" json:"unique_paths_threshold"` + RepeatedHitsThreshold int `yaml:"repeated_hits_threshold" json:"repeated_hits_threshold"` + + // Internal API token for nginx-to-agent communication (auto-generated if empty) + InternalAPIToken string `yaml:"internal_api_token" json:"-"` } func FindConfigPath(providedPath string) string { @@ -245,6 +257,28 @@ func setDefaults(cfg *Config) { if cfg.Security.AutoBlockDuration == 0 { cfg.Security.AutoBlockDuration = 24 * time.Hour } + // Detection threshold defaults + if cfg.Security.DetectionWindow == 0 { + cfg.Security.DetectionWindow = 2 * time.Minute + } + if cfg.Security.NotFoundThreshold == 0 { + cfg.Security.NotFoundThreshold = 10 + } + if cfg.Security.AuthFailureThreshold == 0 { + cfg.Security.AuthFailureThreshold = 5 + } + if cfg.Security.UniquePathsThreshold == 0 { + cfg.Security.UniquePathsThreshold = 20 + } + if cfg.Security.RepeatedHitsThreshold == 0 { + cfg.Security.RepeatedHitsThreshold = 30 + } + if cfg.Security.InternalAPIToken == "" { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err == nil { + cfg.Security.InternalAPIToken = hex.EncodeToString(bytes) + } + } } func Save(cfg *Config, path string) error { diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index 50b2812..79303f9 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -9,6 +9,7 @@ local _M = {} -- Configuration (injected by agent during deployment) local AGENT_IP = "{{.AgentIP}}" local AGENT_PORT = {{.AgentPort}} +local INTERNAL_TOKEN = "{{.InternalAPIToken}}" -- Blocked IPs cache settings local BLOCKED_IPS_CACHE_TTL = 30 -- seconds @@ -110,9 +111,10 @@ function _M.refresh_blocked_ips() local res, req_err = httpc:request({ method = "GET", - path = "/api/security/blocked-ips", + path = "/api/_internal/blocked-ips", headers = { ["Host"] = AGENT_IP .. ":" .. AGENT_PORT, + ["X-Internal-Token"] = INTERNAL_TOKEN, } }) diff --git a/templates/templates.go b/templates/templates.go index 440ee8b..f838bbf 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -92,12 +92,13 @@ func GetNginxSecurityLua() ([]byte, error) { // LuaTemplateData contains the data for Lua template processing type LuaTemplateData struct { - AgentIP string - AgentPort int + AgentIP string + AgentPort int + InternalAPIToken string } // GetNginxSecurityLuaWithConfig returns the security.lua template processed with agent config -func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int) ([]byte, error) { +func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int, internalAPIToken string) ([]byte, error) { content, err := FS.ReadFile("infra/nginx/lua/security.lua") if err != nil { return nil, err @@ -110,8 +111,9 @@ func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int) ([]byte, error var buf bytes.Buffer data := LuaTemplateData{ - AgentIP: agentIP, - AgentPort: agentPort, + AgentIP: agentIP, + AgentPort: agentPort, + InternalAPIToken: internalAPIToken, } if err := tmpl.Execute(&buf, data); err != nil { diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index 95e8811..982fe7e 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -10,6 +10,7 @@ local _M = {} -- Configuration via environment variable (test-specific) local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" +local INTERNAL_TOKEN = os.getenv("FLATRUN_INTERNAL_TOKEN") or "" -- Blocked IPs cache settings local BLOCKED_IPS_CACHE_TTL = 30 -- seconds @@ -47,8 +48,11 @@ function _M.refresh_blocked_ips() local httpc = http.new() httpc:set_timeout(3000) - local res, err = httpc:request_uri(AGENT_URL .. "/api/security/blocked-ips", { + local res, err = httpc:request_uri(AGENT_URL .. "/api/_internal/blocked-ips", { method = "GET", + headers = { + ["X-Internal-Token"] = INTERNAL_TOKEN, + }, }) if not res then