caddy-sip-guardian/enumeration.go
Ryan Malloy c73fa9d3d1 Add extension enumeration detection and comprehensive SIP protection
Major features:
- Extension enumeration detection with 3 detection algorithms:
  - Max unique extensions threshold (default: 20 in 5 min)
  - Sequential pattern detection (e.g., 100,101,102...)
  - Rapid-fire detection (many extensions in short window)
- Prometheus metrics for all SIP Guardian operations
- SQLite persistent storage for bans and attack history
- Webhook notifications for ban/unban/suspicious events
- GeoIP-based country blocking with continent shortcuts
- Per-method rate limiting with token bucket algorithm

Bug fixes:
- Fix whitelist count always reporting zero in stats
- Fix whitelisted connections metric never incrementing
- Fix Caddyfile config not being applied to shared guardian

New files:
- enumeration.go: Extension enumeration detector
- enumeration_test.go: 14 comprehensive unit tests
- metrics.go: Prometheus metrics handler
- storage.go: SQLite persistence layer
- webhooks.go: Webhook notification system
- geoip.go: MaxMind GeoIP integration
- ratelimit.go: Per-method rate limiting

Testing:
- sandbox/ contains complete Docker Compose test environment
- All 14 enumeration tests pass
2025-12-07 15:22:28 -07:00

366 lines
9.4 KiB
Go

package sipguardian
import (
"sort"
"strconv"
"sync"
"time"
"go.uber.org/zap"
)
// EnumerationConfig holds configuration for extension enumeration detection
type EnumerationConfig struct {
// MaxExtensions is the maximum unique extensions an IP can probe before ban
MaxExtensions int `json:"max_extensions,omitempty"`
// ExtensionWindow is the time window for counting unique extensions
ExtensionWindow time.Duration `json:"extension_window,omitempty"`
// SequentialThreshold triggers on N+ consecutive extensions (100,101,102...)
SequentialThreshold int `json:"sequential_threshold,omitempty"`
// RapidFireCount triggers on N extensions in RapidFireWindow
RapidFireCount int `json:"rapid_fire_count,omitempty"`
// RapidFireWindow is the time window for rapid-fire detection
RapidFireWindow time.Duration `json:"rapid_fire_window,omitempty"`
// EnumBanTime is ban duration for enumeration attacks (longer than normal)
EnumBanTime time.Duration `json:"enum_ban_time,omitempty"`
// ExemptExtensions are extensions that don't count toward enumeration
ExemptExtensions []string `json:"exempt_extensions,omitempty"`
}
// DefaultEnumerationConfig returns sensible defaults
func DefaultEnumerationConfig() EnumerationConfig {
return EnumerationConfig{
MaxExtensions: 20,
ExtensionWindow: 5 * time.Minute,
SequentialThreshold: 5,
RapidFireCount: 10,
RapidFireWindow: 30 * time.Second,
EnumBanTime: 2 * time.Hour,
ExemptExtensions: []string{},
}
}
// EnumerationDetector detects extension scanning attacks
type EnumerationDetector struct {
config EnumerationConfig
tracker map[string]*ExtensionAttempts
exemptSet map[string]bool
logger *zap.Logger
mu sync.RWMutex
}
// ExtensionAttempts tracks extension attempts for a single IP
type ExtensionAttempts struct {
extensions map[string]time.Time // extension -> last attempt time
ordered []extAttempt // time-ordered for pattern detection
firstSeen time.Time
lastSeen time.Time
flaggedSeq bool // already detected sequential pattern
mu sync.Mutex
}
type extAttempt struct {
extension string
timestamp time.Time
numeric int // -1 if non-numeric
}
// EnumerationResult contains detection results
type EnumerationResult struct {
Detected bool
Reason string
UniqueCount int
SeqStart int // start of sequential range (if detected)
SeqEnd int // end of sequential range (if detected)
Extensions []string
}
// Global detector instance
var (
globalEnumDetector *EnumerationDetector
enumDetectorMu sync.Mutex
)
// GetEnumerationDetector returns the global enumeration detector
func GetEnumerationDetector(logger *zap.Logger) *EnumerationDetector {
enumDetectorMu.Lock()
defer enumDetectorMu.Unlock()
if globalEnumDetector == nil {
globalEnumDetector = NewEnumerationDetector(logger, DefaultEnumerationConfig())
}
return globalEnumDetector
}
// SetEnumerationConfig updates the global detector configuration
func SetEnumerationConfig(config EnumerationConfig) {
enumDetectorMu.Lock()
defer enumDetectorMu.Unlock()
if globalEnumDetector != nil {
globalEnumDetector.config = config
// Rebuild exempt set
globalEnumDetector.exemptSet = make(map[string]bool)
for _, ext := range config.ExemptExtensions {
globalEnumDetector.exemptSet[ext] = true
}
}
}
// NewEnumerationDetector creates a new detector
func NewEnumerationDetector(logger *zap.Logger, config EnumerationConfig) *EnumerationDetector {
exemptSet := make(map[string]bool)
for _, ext := range config.ExemptExtensions {
exemptSet[ext] = true
}
return &EnumerationDetector{
config: config,
tracker: make(map[string]*ExtensionAttempts),
exemptSet: exemptSet,
logger: logger,
}
}
// RecordAttempt records an extension attempt and returns detection result
func (ed *EnumerationDetector) RecordAttempt(ip, extension string) EnumerationResult {
// Skip exempt extensions
if ed.exemptSet[extension] {
return EnumerationResult{Detected: false}
}
ed.mu.Lock()
attempts, exists := ed.tracker[ip]
if !exists {
attempts = &ExtensionAttempts{
extensions: make(map[string]time.Time),
ordered: make([]extAttempt, 0),
firstSeen: time.Now(),
}
ed.tracker[ip] = attempts
}
ed.mu.Unlock()
return attempts.record(extension, ed.config)
}
// record adds an attempt and checks for enumeration patterns
func (ea *ExtensionAttempts) record(extension string, config EnumerationConfig) EnumerationResult {
ea.mu.Lock()
defer ea.mu.Unlock()
now := time.Now()
ea.lastSeen = now
// Clean old entries
ea.cleanup(config.ExtensionWindow)
// Parse as numeric if possible
numeric := -1
if n, err := strconv.Atoi(extension); err == nil {
numeric = n
}
// Record the attempt (update timestamp if already seen)
if _, exists := ea.extensions[extension]; !exists {
ea.extensions[extension] = now
ea.ordered = append(ea.ordered, extAttempt{
extension: extension,
timestamp: now,
numeric: numeric,
})
} else {
ea.extensions[extension] = now
}
// Check detection rules
result := EnumerationResult{
UniqueCount: len(ea.extensions),
Extensions: ea.getExtensionList(),
}
// Rule 1: Too many unique extensions
if len(ea.extensions) >= config.MaxExtensions {
result.Detected = true
result.Reason = "extension_count_exceeded"
return result
}
// Rule 2: Sequential pattern detection
if !ea.flaggedSeq && len(ea.extensions) >= config.SequentialThreshold {
if detected, start, end := ea.detectSequentialPattern(config.SequentialThreshold); detected {
ea.flaggedSeq = true
result.Detected = true
result.Reason = "sequential_enumeration"
result.SeqStart = start
result.SeqEnd = end
return result
}
}
// Rule 3: Rapid-fire detection
if detected := ea.detectRapidFire(config.RapidFireCount, config.RapidFireWindow); detected {
result.Detected = true
result.Reason = "rapid_fire_enumeration"
return result
}
return result
}
// detectSequentialPattern finds consecutive numeric sequences
func (ea *ExtensionAttempts) detectSequentialPattern(threshold int) (bool, int, int) {
var nums []int
for ext := range ea.extensions {
if n, err := strconv.Atoi(ext); err == nil {
nums = append(nums, n)
}
}
if len(nums) < threshold {
return false, 0, 0
}
sort.Ints(nums)
// Find longest consecutive sequence
maxRun, runStart := 1, nums[0]
currentRun, currentStart := 1, nums[0]
for i := 1; i < len(nums); i++ {
if nums[i] == nums[i-1]+1 {
currentRun++
if currentRun > maxRun {
maxRun = currentRun
runStart = currentStart
}
} else {
currentRun = 1
currentStart = nums[i]
}
}
return maxRun >= threshold, runStart, runStart + maxRun - 1
}
// detectRapidFire checks for many extensions in a short time
func (ea *ExtensionAttempts) detectRapidFire(count int, window time.Duration) bool {
cutoff := time.Now().Add(-window)
seen := make(map[string]bool)
for _, attempt := range ea.ordered {
if attempt.timestamp.After(cutoff) && !seen[attempt.extension] {
seen[attempt.extension] = true
}
}
return len(seen) >= count
}
// cleanup removes old entries outside the window
func (ea *ExtensionAttempts) cleanup(window time.Duration) {
cutoff := time.Now().Add(-window)
// Clean extensions map
for ext, ts := range ea.extensions {
if ts.Before(cutoff) {
delete(ea.extensions, ext)
}
}
// Clean ordered slice
newOrdered := make([]extAttempt, 0, len(ea.ordered))
for _, a := range ea.ordered {
if a.timestamp.After(cutoff) {
newOrdered = append(newOrdered, a)
}
}
ea.ordered = newOrdered
// Reset sequential flag if we've cleaned enough entries
if len(ea.extensions) < 3 {
ea.flaggedSeq = false
}
}
func (ea *ExtensionAttempts) getExtensionList() []string {
exts := make([]string, 0, len(ea.extensions))
for ext := range ea.extensions {
exts = append(exts, ext)
}
return exts
}
// Cleanup removes stale IP entries from the detector
func (ed *EnumerationDetector) Cleanup() {
ed.mu.Lock()
defer ed.mu.Unlock()
cutoff := time.Now().Add(-ed.config.ExtensionWindow * 2)
for ip, attempts := range ed.tracker {
attempts.mu.Lock()
if attempts.lastSeen.Before(cutoff) {
delete(ed.tracker, ip)
}
attempts.mu.Unlock()
}
}
// GetStats returns detector statistics
func (ed *EnumerationDetector) GetStats() map[string]interface{} {
ed.mu.RLock()
defer ed.mu.RUnlock()
// Count total tracked extensions across all IPs
totalExtensions := 0
for _, attempts := range ed.tracker {
attempts.mu.Lock()
totalExtensions += len(attempts.extensions)
attempts.mu.Unlock()
}
return map[string]interface{}{
"tracked_ips": len(ed.tracker),
"total_extensions": totalExtensions,
"max_extensions": ed.config.MaxExtensions,
"sequential_threshold": ed.config.SequentialThreshold,
"rapid_fire_count": ed.config.RapidFireCount,
"extension_window": ed.config.ExtensionWindow.String(),
"rapid_fire_window": ed.config.RapidFireWindow.String(),
}
}
// GetIPAttempts returns the extension attempts for a specific IP (for debugging/admin)
func (ed *EnumerationDetector) GetIPAttempts(ip string) *EnumerationResult {
ed.mu.RLock()
attempts, exists := ed.tracker[ip]
ed.mu.RUnlock()
if !exists {
return nil
}
attempts.mu.Lock()
defer attempts.mu.Unlock()
return &EnumerationResult{
Detected: false,
UniqueCount: len(attempts.extensions),
Extensions: attempts.getExtensionList(),
}
}
// ResetIP clears tracking for a specific IP (for admin use)
func (ed *EnumerationDetector) ResetIP(ip string) {
ed.mu.Lock()
defer ed.mu.Unlock()
delete(ed.tracker, ip)
}