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
This commit is contained in:
parent
0b0fb53c9c
commit
c73fa9d3d1
19 changed files with 4630 additions and 544 deletions
365
enumeration.go
Normal file
365
enumeration.go
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue