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:
Ryan Malloy 2025-12-07 15:22:28 -07:00
parent 0b0fb53c9c
commit c73fa9d3d1
19 changed files with 4630 additions and 544 deletions

107
sandbox/Caddyfile Normal file
View file

@ -0,0 +1,107 @@
# Sandbox Caddyfile for SIP Guardian Testing
#
# This configuration showcases all the new features:
# - Prometheus metrics endpoint
# - Rate limiting per method (built-in defaults)
# - Suspicious pattern detection
#
# Note: Storage and webhooks are configured in JSON config mode,
# as the L4 handler uses the shared global guardian instance
{
debug
admin 0.0.0.0:2019
layer4 {
# SIP over UDP
udp/:5060 {
@sip sip {
methods REGISTER INVITE OPTIONS ACK BYE CANCEL INFO NOTIFY SUBSCRIBE MESSAGE
}
route @sip {
sip_guardian {
max_failures 3 # Lower for faster testing
find_time 2m # Shorter window
ban_time 5m # Short bans for testing
# Whitelist legitimate test clients
whitelist 10.55.0.50/32 # client container
whitelist 10.55.0.51/32 # linphone container
# Enumeration detection (low thresholds for testing)
enumeration {
max_extensions 10
extension_window 2m
sequential_threshold 5
rapid_fire_count 8
rapid_fire_window 10s
ban_time 10m
exempt_extensions 100 200
}
}
proxy udp/{$SIP_UPSTREAM_HOST}:{$SIP_UPSTREAM_PORT}
}
# Unmatched traffic - drop silently
route {
}
}
# SIP over TCP
tcp/:5060 {
@sip sip
route @sip {
sip_guardian {
max_failures 3
find_time 2m
ban_time 5m
whitelist 10.55.0.50/32
whitelist 10.55.0.51/32
}
proxy tcp/{$SIP_UPSTREAM_HOST}:{$SIP_UPSTREAM_PORT}
}
}
# SIP over TLS
tcp/:5061 {
@sip sip
route @sip {
sip_guardian {
max_failures 3
find_time 2m
ban_time 5m
whitelist 10.55.0.50/32
whitelist 10.55.0.51/32
}
proxy tcp/{$SIP_UPSTREAM_HOST}:{$SIP_UPSTREAM_TLS_PORT}
}
}
}
}
# Admin API and Metrics
:2020 {
# SIP Guardian admin endpoints
handle /api/sip-guardian/* {
sip_guardian_admin
}
# Prometheus metrics endpoint
handle /metrics {
sip_guardian_metrics
}
# Health check
handle /health {
respond "OK" 200
}
# Stats (alias for backwards compatibility)
handle /stats {
sip_guardian_admin
}
}

219
sandbox/docker-compose.yml Normal file
View file

@ -0,0 +1,219 @@
# SIP Guardian Testing Sandbox
#
# This provides a complete testing environment with:
# - FreePBX (real PBX for testing)
# - Caddy with SIP Guardian (the proxy under test)
# - Attack containers (sipvicious, custom scripts)
# - Valid client containers (pjsip for legitimate traffic)
#
# Usage:
# docker compose up -d
# docker compose logs -f caddy
# docker compose run --rm attacker sipvicious_svwar -e100-200 caddy
# docker compose run --rm client pjsua --registrar=sip:caddy
services:
# ============================================
# FreePBX - The Protected PBX
# ============================================
freepbx:
image: tiredofit/freepbx:latest
container_name: sandbox-freepbx
hostname: freepbx
restart: unless-stopped
privileged: true
environment:
- VIRTUAL_HOST=pbx.sandbox.local
- VIRTUAL_NETWORK=sandbox
- HTTP_PORT=80
- HTTPS_PORT=443
- UCP_FIRST_RUN=true
- DB_HOST=mariadb
- DB_PORT=3306
- DB_NAME=asterisk
- DB_USER=asterisk
- DB_PASS=asteriskpass
- ENABLE_FAIL2BAN=FALSE # We're replacing this!
- TZ=UTC
volumes:
- freepbx_data:/data
- freepbx_logs:/var/log
- freepbx_www:/var/www/html
depends_on:
- mariadb
networks:
sandbox:
ipv4_address: 10.55.0.10
mariadb:
image: mariadb:10.11
container_name: sandbox-mariadb
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=rootpass
- MYSQL_DATABASE=asterisk
- MYSQL_USER=asterisk
- MYSQL_PASSWORD=asteriskpass
volumes:
- mariadb_data:/var/lib/mysql
networks:
sandbox:
ipv4_address: 10.55.0.11
# ============================================
# Caddy with SIP Guardian - The Proxy
# ============================================
caddy:
build:
context: ..
dockerfile: Dockerfile
container_name: sandbox-caddy
hostname: caddy
restart: unless-stopped
# Override default command to use explicit Caddyfile (ENTRYPOINT is "caddy")
command: ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
ports:
# Expose SIP ports to host for external testing
- "5060:5060/udp"
- "5060:5060/tcp"
- "5061:5061/tcp"
# Admin API
- "2020:2020"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
- SIP_UPSTREAM_HOST=freepbx
- SIP_UPSTREAM_PORT=5060
- SIP_UPSTREAM_TLS_PORT=5061
networks:
sandbox:
ipv4_address: 10.55.0.2
depends_on:
- freepbx
# ============================================
# Attack Simulation Containers
# ============================================
# SIPVicious scanner - for testing scanner detection
attacker:
image: python:3.11-slim
container_name: sandbox-attacker
hostname: attacker
profiles:
- testing
command: >
bash -c "
pip install sipvicious pysip3 &&
echo 'SIPVicious and tools installed. Use: sipvicious_svwar, sipvicious_svcrack, sipvicious_svmap' &&
tail -f /dev/null
"
networks:
sandbox:
ipv4_address: 10.55.0.100
depends_on:
- caddy
# Brute force simulation
bruteforcer:
image: python:3.11-slim
container_name: sandbox-bruteforcer
hostname: bruteforcer
profiles:
- testing
volumes:
- ./scripts:/scripts:ro
command: >
bash -c "
pip install sipsimple requests &&
echo 'Brute force tools ready. Run: python /scripts/bruteforce.py' &&
tail -f /dev/null
"
networks:
sandbox:
ipv4_address: 10.55.0.101
depends_on:
- caddy
# ============================================
# Legitimate Client Containers
# ============================================
# PJSIP-based SIP client
client:
image: alpine:latest
container_name: sandbox-client
hostname: client
profiles:
- testing
command: >
sh -c "
apk add --no-cache pjsua netcat-openbsd curl jq &&
echo 'PJSIP client ready. Use pjsua for SIP registration.' &&
tail -f /dev/null
"
networks:
sandbox:
ipv4_address: 10.55.0.50
depends_on:
- caddy
# Linphone CLI client
linphone:
image: alpine:latest
container_name: sandbox-linphone
hostname: linphone
profiles:
- testing
command: >
sh -c "
apk add --no-cache linphone netcat-openbsd curl &&
echo 'Linphone ready.' &&
tail -f /dev/null
"
networks:
sandbox:
ipv4_address: 10.55.0.51
depends_on:
- caddy
# ============================================
# Monitoring & Debugging
# ============================================
# Network sniffer for SIP traffic analysis
tcpdump:
image: alpine:latest
container_name: sandbox-tcpdump
hostname: tcpdump
profiles:
- debug
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: "service:caddy"
command: >
sh -c "
apk add --no-cache tcpdump &&
tcpdump -i any -n 'udp port 5060 or tcp port 5060 or tcp port 5061' -vvv
"
depends_on:
- caddy
volumes:
freepbx_data:
freepbx_logs:
freepbx_www:
mariadb_data:
caddy_data:
caddy_config:
networks:
sandbox:
driver: bridge
ipam:
config:
- subnet: 10.55.0.0/24
gateway: 10.55.0.1

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
SIP Brute Force Simulation for SIP Guardian Testing
Simulates authentication failures to test rate limiting and banning.
"""
import socket
import time
import argparse
import random
import string
def generate_call_id():
"""Generate a random SIP Call-ID"""
return ''.join(random.choices(string.ascii_letters + string.digits, k=32))
def generate_branch():
"""Generate a random Via branch parameter"""
return 'z9hG4bK' + ''.join(random.choices(string.ascii_letters + string.digits, k=16))
def create_register_request(target_host: str, target_port: int, extension: str, from_ip: str) -> bytes:
"""Create a SIP REGISTER request"""
call_id = generate_call_id()
branch = generate_branch()
tag = ''.join(random.choices(string.digits, k=8))
request = f"""REGISTER sip:{target_host}:{target_port} SIP/2.0\r
Via: SIP/2.0/UDP {from_ip}:5060;branch={branch}\r
Max-Forwards: 70\r
From: <sip:{extension}@{target_host}>;tag={tag}\r
To: <sip:{extension}@{target_host}>\r
Call-ID: {call_id}@{from_ip}\r
CSeq: 1 REGISTER\r
Contact: <sip:{extension}@{from_ip}:5060>\r
Expires: 3600\r
User-Agent: BruteForcer/1.0\r
Content-Length: 0\r
\r
"""
return request.encode()
def send_register(sock: socket.socket, target: tuple, request: bytes) -> str:
"""Send REGISTER and receive response"""
try:
sock.sendto(request, target)
sock.settimeout(2.0)
response, _ = sock.recvfrom(4096)
return response.decode()
except socket.timeout:
return "TIMEOUT"
except Exception as e:
return f"ERROR: {e}"
def main():
parser = argparse.ArgumentParser(description='SIP Brute Force Simulator')
parser.add_argument('target', help='Target host (Caddy proxy)')
parser.add_argument('-p', '--port', type=int, default=5060, help='Target port')
parser.add_argument('-e', '--extensions', default='100-105', help='Extension range (e.g., 100-200)')
parser.add_argument('-c', '--count', type=int, default=10, help='Attempts per extension')
parser.add_argument('-d', '--delay', type=float, default=0.1, help='Delay between attempts')
parser.add_argument('--udp', action='store_true', default=True, help='Use UDP (default)')
parser.add_argument('--tcp', action='store_true', help='Use TCP')
args = parser.parse_args()
# Parse extension range
if '-' in args.extensions:
start, end = map(int, args.extensions.split('-'))
extensions = list(range(start, end + 1))
else:
extensions = [int(args.extensions)]
# Get our IP
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect((args.target, args.port))
from_ip = sock.getsockname()[0]
sock.close()
print(f"[*] Starting brute force simulation")
print(f"[*] Target: {args.target}:{args.port}")
print(f"[*] Source IP: {from_ip}")
print(f"[*] Extensions: {extensions}")
print(f"[*] Attempts per extension: {args.count}")
print()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
target = (args.target, args.port)
attempts = 0
blocked_at = None
for ext in extensions:
for i in range(args.count):
request = create_register_request(args.target, args.port, str(ext), from_ip)
response = send_register(sock, target, request)
attempts += 1
if 'TIMEOUT' in response:
if blocked_at is None:
blocked_at = attempts
print(f"[!] BLOCKED after {attempts} attempts (extension {ext}, attempt {i+1})")
print(f"[+] SIP Guardian is working! Blocked after {blocked_at} attempts")
return
elif '401' in response or '407' in response:
print(f"[*] Auth required: ext={ext} attempt={i+1} total={attempts}")
elif '403' in response:
print(f"[!] FORBIDDEN: ext={ext} - Connection blocked")
if blocked_at is None:
blocked_at = attempts
else:
# Print first line of response
first_line = response.split('\r\n')[0] if response else 'No response'
print(f"[?] Response: {first_line} (ext={ext})")
time.sleep(args.delay)
print(f"\n[*] Completed {attempts} attempts without being blocked")
print("[!] SIP Guardian may not be working correctly")
sock.close()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Valid SIP Registration Test
Tests that legitimate registrations pass through SIP Guardian successfully.
"""
import socket
import time
import argparse
import random
import string
import hashlib
def generate_call_id():
return ''.join(random.choices(string.ascii_letters + string.digits, k=32))
def generate_branch():
return 'z9hG4bK' + ''.join(random.choices(string.ascii_letters + string.digits, k=16))
def compute_digest_response(username: str, password: str, realm: str, nonce: str, uri: str, method: str = 'REGISTER') -> str:
"""Compute SIP Digest authentication response"""
ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
response = hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest()
return response
def create_register_with_auth(target_host: str, target_port: int, extension: str,
password: str, from_ip: str, realm: str, nonce: str) -> bytes:
"""Create an authenticated SIP REGISTER request"""
call_id = generate_call_id()
branch = generate_branch()
tag = ''.join(random.choices(string.digits, k=8))
uri = f"sip:{target_host}:{target_port}"
response = compute_digest_response(extension, password, realm, nonce, uri)
request = f"""REGISTER sip:{target_host}:{target_port} SIP/2.0\r
Via: SIP/2.0/UDP {from_ip}:5060;branch={branch}\r
Max-Forwards: 70\r
From: <sip:{extension}@{target_host}>;tag={tag}\r
To: <sip:{extension}@{target_host}>\r
Call-ID: {call_id}@{from_ip}\r
CSeq: 2 REGISTER\r
Contact: <sip:{extension}@{from_ip}:5060>\r
Authorization: Digest username="{extension}",realm="{realm}",nonce="{nonce}",uri="{uri}",response="{response}",algorithm=MD5\r
Expires: 3600\r
User-Agent: ValidClient/1.0\r
Content-Length: 0\r
\r
"""
return request.encode()
def create_initial_register(target_host: str, target_port: int, extension: str, from_ip: str) -> bytes:
"""Create initial REGISTER without auth (to get challenge)"""
call_id = generate_call_id()
branch = generate_branch()
tag = ''.join(random.choices(string.digits, k=8))
request = f"""REGISTER sip:{target_host}:{target_port} SIP/2.0\r
Via: SIP/2.0/UDP {from_ip}:5060;branch={branch}\r
Max-Forwards: 70\r
From: <sip:{extension}@{target_host}>;tag={tag}\r
To: <sip:{extension}@{target_host}>\r
Call-ID: {call_id}@{from_ip}\r
CSeq: 1 REGISTER\r
Contact: <sip:{extension}@{from_ip}:5060>\r
Expires: 3600\r
User-Agent: ValidClient/1.0\r
Content-Length: 0\r
\r
"""
return request.encode()
def parse_www_authenticate(response: str) -> tuple:
"""Parse WWW-Authenticate header to get realm and nonce"""
for line in response.split('\r\n'):
if line.lower().startswith('www-authenticate:'):
# Extract realm
realm_start = line.find('realm="') + 7
realm_end = line.find('"', realm_start)
realm = line[realm_start:realm_end] if realm_start > 6 else ''
# Extract nonce
nonce_start = line.find('nonce="') + 7
nonce_end = line.find('"', nonce_start)
nonce = line[nonce_start:nonce_end] if nonce_start > 6 else ''
return realm, nonce
return '', ''
def main():
parser = argparse.ArgumentParser(description='Valid SIP Registration Test')
parser.add_argument('target', help='Target host (Caddy proxy)')
parser.add_argument('-p', '--port', type=int, default=5060, help='Target port')
parser.add_argument('-e', '--extension', default='100', help='Extension to register')
parser.add_argument('-s', '--secret', default='password123', help='Extension password')
parser.add_argument('-r', '--repeat', type=int, default=1, help='Number of registration cycles')
args = parser.parse_args()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect((args.target, args.port))
from_ip = sock.getsockname()[0]
sock.close()
print(f"[*] Valid Registration Test")
print(f"[*] Target: {args.target}:{args.port}")
print(f"[*] Extension: {args.extension}")
print(f"[*] Source IP: {from_ip}")
print()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
target = (args.target, args.port)
for cycle in range(args.repeat):
print(f"[*] Registration cycle {cycle + 1}/{args.repeat}")
# Send initial REGISTER
request = create_initial_register(args.target, args.port, args.extension, from_ip)
sock.sendto(request, target)
try:
sock.settimeout(5.0)
response, _ = sock.recvfrom(4096)
response = response.decode()
except socket.timeout:
print("[!] TIMEOUT - Connection may be blocked")
continue
first_line = response.split('\r\n')[0]
print(f"[*] Initial response: {first_line}")
if '401' in response or '407' in response:
# Parse auth challenge
realm, nonce = parse_www_authenticate(response)
print(f"[*] Got challenge: realm={realm}")
# Send authenticated REGISTER
auth_request = create_register_with_auth(
args.target, args.port, args.extension, args.secret,
from_ip, realm, nonce
)
sock.sendto(auth_request, target)
try:
response, _ = sock.recvfrom(4096)
response = response.decode()
first_line = response.split('\r\n')[0]
print(f"[*] Auth response: {first_line}")
if '200' in response:
print("[+] SUCCESS - Registration completed!")
else:
print(f"[!] Failed: {first_line}")
except socket.timeout:
print("[!] TIMEOUT after auth - may be blocked")
elif '200' in response:
print("[+] SUCCESS - Already registered or no auth required")
else:
print(f"[?] Unexpected response: {first_line}")
if cycle < args.repeat - 1:
time.sleep(2)
sock.close()
if __name__ == '__main__':
main()