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
344
webhooks.go
Normal file
344
webhooks.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
package sipguardian
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebhookEvent represents an event to be sent via webhook
|
||||
type WebhookEvent struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// BanEventData contains data for ban/unban events
|
||||
type BanEventData struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BannedAt time.Time `json:"banned_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
HitCount int `json:"hit_count,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// SuspiciousEventData contains data for suspicious activity events
|
||||
type SuspiciousEventData struct {
|
||||
IP string `json:"ip"`
|
||||
Pattern string `json:"pattern"`
|
||||
Sample string `json:"sample,omitempty"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
}
|
||||
|
||||
// EnumerationEventData contains data for enumeration detection events
|
||||
type EnumerationEventData struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
UniqueCount int `json:"unique_count"`
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
SeqStart int `json:"seq_start,omitempty"`
|
||||
SeqEnd int `json:"seq_end,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookConfig holds webhook configuration
|
||||
type WebhookConfig struct {
|
||||
// URL to send webhook events to
|
||||
URL string `json:"url"`
|
||||
|
||||
// Secret for HMAC signature (optional)
|
||||
Secret string `json:"secret,omitempty"`
|
||||
|
||||
// Events to subscribe to (default: all)
|
||||
// Options: "ban", "unban", "suspicious", "failure"
|
||||
Events []string `json:"events,omitempty"`
|
||||
|
||||
// Timeout for webhook requests
|
||||
Timeout time.Duration `json:"timeout,omitempty"`
|
||||
|
||||
// RetryCount for failed webhook deliveries
|
||||
RetryCount int `json:"retry_count,omitempty"`
|
||||
|
||||
// Headers to include in webhook requests
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookManager handles webhook dispatching
|
||||
type WebhookManager struct {
|
||||
configs []WebhookConfig
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
|
||||
// Channel for async event dispatching
|
||||
eventChan chan WebhookEvent
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Global webhook manager instance
|
||||
var (
|
||||
webhookManager *WebhookManager
|
||||
webhookMu sync.Mutex
|
||||
)
|
||||
|
||||
// GetWebhookManager returns the global webhook manager, creating it if necessary
|
||||
func GetWebhookManager(logger *zap.Logger) *WebhookManager {
|
||||
webhookMu.Lock()
|
||||
defer webhookMu.Unlock()
|
||||
|
||||
if webhookManager == nil {
|
||||
webhookManager = &WebhookManager{
|
||||
configs: []WebhookConfig{},
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
logger: logger,
|
||||
eventChan: make(chan WebhookEvent, 100),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go webhookManager.dispatcher()
|
||||
}
|
||||
|
||||
return webhookManager
|
||||
}
|
||||
|
||||
// AddWebhook registers a new webhook endpoint
|
||||
func (wm *WebhookManager) AddWebhook(config WebhookConfig) {
|
||||
wm.mu.Lock()
|
||||
defer wm.mu.Unlock()
|
||||
|
||||
// Set defaults
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.RetryCount == 0 {
|
||||
config.RetryCount = 3
|
||||
}
|
||||
if len(config.Events) == 0 {
|
||||
config.Events = []string{"ban", "unban", "suspicious", "failure", "enumeration"}
|
||||
}
|
||||
|
||||
wm.configs = append(wm.configs, config)
|
||||
wm.logger.Info("Webhook registered",
|
||||
zap.String("url", config.URL),
|
||||
zap.Strings("events", config.Events),
|
||||
)
|
||||
}
|
||||
|
||||
// ClearWebhooks removes all registered webhooks
|
||||
func (wm *WebhookManager) ClearWebhooks() {
|
||||
wm.mu.Lock()
|
||||
defer wm.mu.Unlock()
|
||||
wm.configs = []WebhookConfig{}
|
||||
}
|
||||
|
||||
// Emit sends an event to all subscribed webhooks
|
||||
func (wm *WebhookManager) Emit(eventType string, data interface{}) {
|
||||
event := WebhookEvent{
|
||||
Type: eventType,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Data: data,
|
||||
}
|
||||
|
||||
select {
|
||||
case wm.eventChan <- event:
|
||||
// Event queued
|
||||
default:
|
||||
wm.logger.Warn("Webhook event queue full, dropping event",
|
||||
zap.String("type", eventType),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatcher processes events from the channel
|
||||
func (wm *WebhookManager) dispatcher() {
|
||||
for {
|
||||
select {
|
||||
case <-wm.done:
|
||||
return
|
||||
case event := <-wm.eventChan:
|
||||
wm.dispatch(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch sends an event to all matching webhooks
|
||||
func (wm *WebhookManager) dispatch(event WebhookEvent) {
|
||||
wm.mu.RLock()
|
||||
configs := make([]WebhookConfig, len(wm.configs))
|
||||
copy(configs, wm.configs)
|
||||
wm.mu.RUnlock()
|
||||
|
||||
for _, config := range configs {
|
||||
if wm.shouldSend(config, event.Type) {
|
||||
go wm.send(config, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSend checks if an event type matches the webhook's subscriptions
|
||||
func (wm *WebhookManager) shouldSend(config WebhookConfig, eventType string) bool {
|
||||
for _, e := range config.Events {
|
||||
if e == eventType || e == "all" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// send delivers a webhook event with retries
|
||||
func (wm *WebhookManager) send(config WebhookConfig, event WebhookEvent) {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
wm.logger.Error("Failed to marshal webhook event",
|
||||
zap.Error(err),
|
||||
zap.String("type", event.Type),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= config.RetryCount; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff
|
||||
time.Sleep(time.Duration(attempt*attempt) * time.Second)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.URL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
cancel()
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "SIP-Guardian-Webhook/1.0")
|
||||
req.Header.Set("X-SIP-Guardian-Event", event.Type)
|
||||
|
||||
// Add custom headers
|
||||
for k, v := range config.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Add HMAC signature if secret is configured
|
||||
if config.Secret != "" {
|
||||
signature := computeHMAC(payload, config.Secret)
|
||||
req.Header.Set("X-SIP-Guardian-Signature", signature)
|
||||
}
|
||||
|
||||
resp, err := wm.client.Do(req)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
wm.logger.Debug("Webhook delivery failed, retrying",
|
||||
zap.String("url", config.URL),
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
wm.logger.Debug("Webhook delivered successfully",
|
||||
zap.String("url", config.URL),
|
||||
zap.String("type", event.Type),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
lastErr = fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
wm.logger.Debug("Webhook returned non-success status",
|
||||
zap.String("url", config.URL),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Int("attempt", attempt+1),
|
||||
)
|
||||
}
|
||||
|
||||
wm.logger.Error("Webhook delivery failed after retries",
|
||||
zap.String("url", config.URL),
|
||||
zap.String("type", event.Type),
|
||||
zap.Error(lastErr),
|
||||
)
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the webhook manager
|
||||
func (wm *WebhookManager) Stop() {
|
||||
close(wm.done)
|
||||
}
|
||||
|
||||
// computeHMAC generates an HMAC-SHA256 signature for webhook verification
|
||||
func computeHMAC(payload []byte, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Helper functions for emitting specific events
|
||||
|
||||
// EmitBanEvent sends a ban notification
|
||||
func EmitBanEvent(logger *zap.Logger, entry *BanEntry) {
|
||||
wm := GetWebhookManager(logger)
|
||||
wm.Emit("ban", BanEventData{
|
||||
IP: entry.IP,
|
||||
Reason: entry.Reason,
|
||||
BannedAt: entry.BannedAt,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
HitCount: entry.HitCount,
|
||||
Duration: entry.ExpiresAt.Sub(entry.BannedAt).String(),
|
||||
})
|
||||
}
|
||||
|
||||
// EmitUnbanEvent sends an unban notification
|
||||
func EmitUnbanEvent(logger *zap.Logger, ip string, reason string) {
|
||||
wm := GetWebhookManager(logger)
|
||||
wm.Emit("unban", BanEventData{
|
||||
IP: ip,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// EmitSuspiciousEvent sends a suspicious activity notification
|
||||
func EmitSuspiciousEvent(logger *zap.Logger, ip, pattern, sample string, failureCount int) {
|
||||
wm := GetWebhookManager(logger)
|
||||
wm.Emit("suspicious", SuspiciousEventData{
|
||||
IP: ip,
|
||||
Pattern: pattern,
|
||||
Sample: sample,
|
||||
FailureCount: failureCount,
|
||||
})
|
||||
}
|
||||
|
||||
// EmitFailureEvent sends a failure notification
|
||||
func EmitFailureEvent(logger *zap.Logger, ip, reason string, count int) {
|
||||
wm := GetWebhookManager(logger)
|
||||
wm.Emit("failure", map[string]interface{}{
|
||||
"ip": ip,
|
||||
"reason": reason,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// EmitEnumerationEvent sends an enumeration detection notification
|
||||
func EmitEnumerationEvent(logger *zap.Logger, ip string, result EnumerationResult) {
|
||||
wm := GetWebhookManager(logger)
|
||||
wm.Emit("enumeration", EnumerationEventData{
|
||||
IP: ip,
|
||||
Reason: result.Reason,
|
||||
UniqueCount: result.UniqueCount,
|
||||
Extensions: result.Extensions,
|
||||
SeqStart: result.SeqStart,
|
||||
SeqEnd: result.SeqEnd,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue