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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions internal/api/security_handlers.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package api

import (
"encoding/json"
"fmt"
"log"
"net/http"
"os/exec"
"strconv"
"time"

Expand All @@ -20,22 +24,37 @@ func (s *Server) ingestSecurityEvent(c *gin.Context) {

var event security.IngestEvent
if err := c.ShouldBindJSON(&event); err != nil {
clientIP := c.ClientIP()
log.Printf("Security ingest: failed to parse JSON from %s: %v", clientIP, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

secEvent, err := s.securityManager.IngestEvent(&event)
result, err := s.securityManager.IngestEvent(&event, s.config.Security.AutoBlockDuration)
if err != nil {
log.Printf("Security ingest: failed to process event from IP %s (path=%s, method=%s): %v",
event.SourceIP, event.RequestPath, event.RequestMethod, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

if secEvent == nil {
if result.Event == nil {
c.JSON(http.StatusOK, gin.H{"processed": false, "reason": "Event not security-relevant or IP blocked"})
return
}

c.JSON(http.StatusCreated, gin.H{"processed": true, "event": secEvent})
// If an IP was auto-blocked, notify nginx immediately
if result.AutoBlocked {
if err := s.notifyNginxBlockIP(result.BlockedIP, result.BlockTTL); err != nil {
log.Printf("Warning: failed to notify nginx about auto-blocked IP %s: %v", result.BlockedIP, err)
}
}

c.JSON(http.StatusCreated, gin.H{
"processed": true,
"event": result.Event,
"auto_blocked": result.AutoBlocked,
})
}

// getSecurityStats returns security statistics
Expand Down Expand Up @@ -198,6 +217,11 @@ func (s *Server) blockIP(c *gin.Context) {
return
}

// Notify nginx to immediately block the IP
if err := s.notifyNginxBlockIP(req.IP, req.Duration); err != nil {
log.Printf("Warning: failed to notify nginx about blocked IP %s: %v", req.IP, err)
}

c.JSON(http.StatusCreated, gin.H{"id": id, "message": "IP blocked successfully"})
}

Expand All @@ -219,6 +243,11 @@ func (s *Server) unblockIP(c *gin.Context) {
return
}

// Notify nginx to immediately unblock the IP
if err := s.notifyNginxUnblockIP(ip); err != nil {
log.Printf("Warning: failed to notify nginx about unblocked IP %s: %v", ip, err)
}

c.JSON(http.StatusOK, gin.H{"message": "IP unblocked successfully"})
}

Expand Down Expand Up @@ -699,6 +728,69 @@ func (s *Server) updateSecuritySettings(c *gin.Context) {
c.JSON(http.StatusOK, result)
}

// notifyNginxBlockIP notifies nginx to immediately add an IP to its blocked list
func (s *Server) notifyNginxBlockIP(ip string, ttlSeconds int) error {
if s.config.Nginx.ContainerName == "" {
return fmt.Errorf("nginx container name not configured")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value 86400 * 365 represents 1 year in seconds. It's a 'magic number' that would be clearer if defined as a named constant (e.g., security.PermanentBlockDurationSeconds). This improves readability and makes future modifications easier.

Suggested change
return fmt.Errorf("nginx container name not configured")
ttlSeconds = security.PermanentBlockDurationSeconds // Defined as 86400 * 365 elsewhere

}

if ttlSeconds <= 0 {
ttlSeconds = 86400 * 365 // 1 year for permanent blocks
}

payload := map[string]interface{}{
"ip": ip,
"ttl": ttlSeconds,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}

curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/block-ip`,
string(jsonPayload),
)

cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}
Comment on lines +743 to +759
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using docker exec curl for inter-container communication is generally discouraged. It introduces overhead, relies on curl and sh being available in the Nginx container, and can have security implications if the curlCmd is not properly sanitized. A more robust and performant solution would be to use a native Go HTTP client to send requests to Nginx's internal API, leveraging Docker's internal networking (e.g., http://nginx_container_name:8081). This would remove external dependencies and simplify error handling.

Additionally, consider defining the internal API path _internal/security/block-ip as a constant.

Suggested change
"ttl": ttlSeconds,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/block-ip`,
string(jsonPayload),
)
cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}
// Recommended: Use a native Go HTTP client for robustness and performance.
// Example (conceptual, actual implementation might vary based on network setup):
// client := &http.Client{Timeout: 5 * time.Second}
// req, err := http.NewRequest("POST", fmt.Sprintf("http://%s:8081/_internal/security/block-ip", s.config.Nginx.ContainerName), bytes.NewBuffer(jsonPayload))
// if err != nil { return fmt.Errorf("failed to create request: %w", err) }
// req.Header.Set("Content-Type", "application/json")
// resp, err := client.Do(req)
// if err != nil { return fmt.Errorf("failed to notify nginx: %w", err) }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK { /* handle error */ }
// Current implementation using docker exec curl:
curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/block-ip`,
string(jsonPayload),
)
cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}


log.Printf("Notified nginx to block IP %s (ttl=%ds): %s", ip, ttlSeconds, string(output))
return nil
}

// notifyNginxUnblockIP notifies nginx to immediately remove an IP from its blocked list
func (s *Server) notifyNginxUnblockIP(ip string) error {
if s.config.Nginx.ContainerName == "" {
return fmt.Errorf("nginx container name not configured")
}

payload := map[string]interface{}{
"ip": ip,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}

curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/unblock-ip`,
string(jsonPayload),
)

cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}
Comment on lines +776 to +788
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to notifyNginxBlockIP, using docker exec curl for notifyNginxUnblockIP introduces unnecessary dependencies and overhead. A native Go HTTP client is preferred for inter-process communication within a Docker environment. Also, consider defining the _internal/security/unblock-ip path as a constant.

Suggested change
return fmt.Errorf("failed to marshal payload: %w", err)
}
curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/unblock-ip`,
string(jsonPayload),
)
cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}
// Recommended: Use a native Go HTTP client.
// (Similar conceptual implementation as notifyNginxBlockIP)
curlCmd := fmt.Sprintf(
`curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/unblock-ip`,
string(jsonPayload),
)
cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err)
}


log.Printf("Notified nginx to unblock IP %s: %s", ip, string(output))
return nil
}

// refreshSecurityScripts regenerates Lua scripts with correct agent IP and reloads nginx
func (s *Server) refreshSecurityScripts(c *gin.Context) {
if !s.config.Security.Enabled {
Expand Down
55 changes: 55 additions & 0 deletions internal/infra/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,11 +686,41 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck {
result.Checks["nginx_conf_has_global_traffic_logging"] = false
result.Issues = append(result.Issues, "nginx.conf does not have global traffic logging enabled")
}

// Check for IP blocking shared dict
if strings.Contains(contentStr, "lua_shared_dict blocked_ips") {
result.Checks["nginx_conf_has_blocked_ips_dict"] = true
} else {
result.Checks["nginx_conf_has_blocked_ips_dict"] = false
result.Issues = append(result.Issues, "nginx.conf missing lua_shared_dict blocked_ips for IP blocking")
result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking support")
}

// Check for IP blocking access check
if strings.Contains(contentStr, "access_by_lua_block") && strings.Contains(contentStr, "security.is_blocked") {
result.Checks["nginx_conf_has_ip_blocking"] = true
} else {
result.Checks["nginx_conf_has_ip_blocking"] = false
result.Issues = append(result.Issues, "nginx.conf missing access_by_lua_block for IP blocking")
result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking")
}

// Check for internal API server block
if strings.Contains(contentStr, "listen 127.0.0.1:8081") {
result.Checks["nginx_conf_has_internal_api"] = true
} else {
result.Checks["nginx_conf_has_internal_api"] = false
result.Issues = append(result.Issues, "nginx.conf missing internal API server for instant IP blocking")
result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with internal API")
}
} else {
result.Checks["nginx_conf_exists"] = false
result.Checks["nginx_conf_has_lua_init"] = false
result.Checks["nginx_conf_has_traffic_module"] = false
result.Checks["nginx_conf_has_global_traffic_logging"] = false
result.Checks["nginx_conf_has_blocked_ips_dict"] = false
result.Checks["nginx_conf_has_ip_blocking"] = false
result.Checks["nginx_conf_has_internal_api"] = false
result.Issues = append(result.Issues, "nginx.conf does not exist at "+nginxConfPath)
result.Recommendations = append(result.Recommendations, "Enable realtime capture in Security settings")
}
Expand Down Expand Up @@ -815,6 +845,16 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck {
result.Recommendations = append(result.Recommendations,
"3. Use POST /api/security/refresh to regenerate scripts with correct IP")
}

// Check 9: Internal API (port 8081) is reachable for instant IP blocking
internalAPIReachable := m.checkNginxInternalAPIReachable()
result.Checks["nginx_internal_api_reachable"] = internalAPIReachable
if !internalAPIReachable {
result.Issues = append(result.Issues,
"Nginx internal API (port 8081) is not responding - instant IP blocking will not work")
result.Recommendations = append(result.Recommendations,
"Check nginx error logs for Lua errors, ensure nginx.conf has internal server block on 127.0.0.1:8081")
}
}

// Determine overall status
Expand All @@ -824,11 +864,15 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck {
"traffic_lua_exists",
"traffic_lua_ip_injected",
"nginx_conf_has_lua_init",
"nginx_conf_has_blocked_ips_dict",
"nginx_conf_has_ip_blocking",
"nginx_conf_has_internal_api",
"nginx_container_running",
"nginx_lua_module_loaded",
"nginx_conf_mounted",
"lua_directory_mounted",
"nginx_can_reach_agent",
"nginx_internal_api_reachable",
"vhosts_have_security_hook",
}

Expand Down Expand Up @@ -990,6 +1034,17 @@ func (m *Manager) checkNginxCanReachAgent(agentIP string, agentPort int) bool {
return strings.Contains(string(output), "yes")
}

func (m *Manager) checkNginxInternalAPIReachable() bool {
testCmd := "curl -s --connect-timeout 2 --max-time 5 -X POST http://127.0.0.1:8081/_internal/security/refresh-blocked-ips 2>/dev/null | grep -q success && echo yes || echo no"

cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", testCmd)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), "yes")
}
Comment on lines +1037 to +1046
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkNginxInternalAPIReachable function also uses docker exec curl. While acceptable for a health check, this still carries the overhead and dependency concerns mentioned for the notifyNginx*IP functions. If curl is not available or its behavior changes in the Nginx container, this health check might fail unexpectedly. Consider a more direct Go-based health check if possible within your container networking setup.

Suggested change
func (m *Manager) checkNginxInternalAPIReachable() bool {
testCmd := "curl -s --connect-timeout 2 --max-time 5 -X POST http://127.0.0.1:8081/_internal/security/refresh-blocked-ips 2>/dev/null | grep -q success && echo yes || echo no"
cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", testCmd)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), "yes")
}
func (m *Manager) checkNginxInternalAPIReachable() bool {
// Recommended: Use a native Go HTTP client for a more reliable health check.
// Example (conceptual):
// client := &http.Client{Timeout: 2 * time.Second}
// resp, err := client.Post(fmt.Sprintf("http://%s:8081/_internal/security/refresh-blocked-ips", m.config.Nginx.ContainerName), "application/json", nil)
// if err != nil { return false }
// defer resp.Body.Close()
// return resp.StatusCode == http.StatusOK // Or parse body for a specific success message
// Current implementation:
testCmd := "curl -s --connect-timeout 2 --max-time 5 -X POST http://127.0.0.1:8081/_internal/security/refresh-blocked-ips 2>/dev/null | grep -q success && echo yes || echo no"
cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", testCmd)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), "yes")
}


// securityVolumeMounts are added when security is enabled and removed when disabled
var securityVolumeMounts = []string{
"./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro",
Expand Down
90 changes: 69 additions & 21 deletions internal/security/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,50 @@ type Detector struct {
mu sync.RWMutex

// Thresholds
rateWindowDuration time.Duration
rateThreshold int
autoBlockThreshold int
windowDuration time.Duration
rateThreshold int // high request rate
notFoundThreshold int // 404 responses
authFailureThreshold int // 401/403 responses
uniquePathsThreshold int // scanning many different paths
repeatedHitsThreshold int // hammering same path
}

type requestWindow struct {
count int
windowEnd time.Time
count int
notFoundHits int // 404 responses
authFailures int // 401/403 responses
pathHits map[string]int // path -> hit count
windowEnd time.Time
}

func NewDetector() *Detector {
return &Detector{
ipRequestCount: make(map[string]*requestWindow),
rateWindowDuration: time.Minute,
rateThreshold: 100,
autoBlockThreshold: 50,
ipRequestCount: make(map[string]*requestWindow),
windowDuration: 2 * time.Minute,
rateThreshold: 60, // 60 requests in 2 min
notFoundThreshold: 10, // 10 404s in 2 min
authFailureThreshold: 5, // 5 auth failures in 2 min
uniquePathsThreshold: 20, // 20 different paths in 2 min
repeatedHitsThreshold: 30, // 30 hits to same path in 2 min
}
}

// SetThresholds configures detection thresholds
func (d *Detector) SetThresholds(rateThreshold, autoBlockThreshold int, windowDuration time.Duration) {
func (d *Detector) SetThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold int, windowDuration time.Duration) {
d.mu.Lock()
defer d.mu.Unlock()
d.rateThreshold = rateThreshold
d.autoBlockThreshold = autoBlockThreshold
d.rateWindowDuration = windowDuration
d.notFoundThreshold = notFoundThreshold
d.authFailureThreshold = authFailureThreshold
d.uniquePathsThreshold = uniquePathsThreshold
d.repeatedHitsThreshold = repeatedHitsThreshold
d.windowDuration = windowDuration
}

// Classify analyzes an incoming event and creates a SecurityEvent if it's security-relevant
func (d *Detector) Classify(event *IngestEvent) *SecurityEvent {
// Track request rate
highRate := d.trackRequestRate(event.SourceIP)
// Track request behavior
highRate := d.trackRequest(event.SourceIP, event)

var eventType, severity, message string

Expand Down Expand Up @@ -118,8 +130,8 @@ func (d *Detector) Classify(event *IngestEvent) *SecurityEvent {
}
}

// trackRequestRate tracks request rate per IP and returns true if rate is too high
func (d *Detector) trackRequestRate(ip string) bool {
// trackRequest tracks request behavior per IP and returns true if rate is too high
func (d *Detector) trackRequest(ip string, event *IngestEvent) bool {
d.mu.Lock()
defer d.mu.Unlock()

Expand All @@ -129,12 +141,27 @@ func (d *Detector) trackRequestRate(ip string) bool {
if !exists || now.After(window.windowEnd) {
d.ipRequestCount[ip] = &requestWindow{
count: 1,
windowEnd: now.Add(d.rateWindowDuration),
pathHits: make(map[string]int),
windowEnd: now.Add(d.windowDuration),
}
return false
window = d.ipRequestCount[ip]
} else {
window.count++
}

// Track hits per path
window.pathHits[event.RequestPath]++

// Track 404 responses (probing for files/paths)
if event.StatusCode == 404 {
window.notFoundHits++
}

// Track auth failures (401, 403)
if event.StatusCode == 401 || event.StatusCode == 403 {
window.authFailures++
}

window.count++
return window.count > d.rateThreshold
}

Expand All @@ -150,15 +177,36 @@ func (d *Detector) ShouldAutoBlock(ip string, event *SecurityEvent) bool {
return true
}

// Check for repeated critical events
d.mu.RLock()
window, exists := d.ipRequestCount[ip]
d.mu.RUnlock()

if exists && window.count > d.autoBlockThreshold && event.Severity == SeverityCritical {
if !exists {
return false
}

// Auto-block after too many 404s (probing for files/paths)
if window.notFoundHits >= d.notFoundThreshold {
return true
}

// Auto-block after too many auth failures
if window.authFailures >= d.authFailureThreshold {
return true
}

// Auto-block if trying too many unique paths (scanning)
if len(window.pathHits) >= d.uniquePathsThreshold {
return true
}

// Auto-block if hammering same path repeatedly
for _, hits := range window.pathHits {
if hits >= d.repeatedHitsThreshold {
return true
}
}

return false
}

Expand Down
Loading
Loading