package sipguardian import ( "bytes" "regexp" "strings" "testing" ) // ============================================================================= // SIP Matcher Tests - Verifying SIP traffic is correctly identified // ============================================================================= // provisionMatcherForTest creates a SIPMatcher with default methods without requiring Caddy context func provisionMatcherForTest(methods []string) *SIPMatcher { if len(methods) == 0 { methods = []string{"REGISTER", "INVITE", "OPTIONS", "ACK", "BYE", "CANCEL", "INFO", "NOTIFY", "SUBSCRIBE", "MESSAGE"} } pattern := "^(" + strings.Join(methods, "|") + ") sip:" return &SIPMatcher{ Methods: methods, methodRegex: regexp.MustCompile("(?i)" + pattern), } } func TestSIPMethodPatternMatching(t *testing.T) { // Create a provisioned matcher using our test helper m := provisionMatcherForTest(nil) tests := []struct { name string data []byte expected bool }{ // Legitimate SIP requests - MUST match { name: "REGISTER request", data: []byte("REGISTER sip:example.com SIP/2.0\r\n"), expected: true, }, { name: "INVITE request", data: []byte("INVITE sip:alice@example.com SIP/2.0\r\n"), expected: true, }, { name: "OPTIONS request", data: []byte("OPTIONS sip:example.com SIP/2.0\r\n"), expected: true, }, { name: "ACK request", data: []byte("ACK sip:alice@example.com SIP/2.0\r\n"), expected: true, }, { name: "BYE request", data: []byte("BYE sip:alice@192.168.1.100 SIP/2.0\r\n"), expected: true, }, { name: "CANCEL request", data: []byte("CANCEL sip:bob@pbx.local SIP/2.0\r\n"), expected: true, }, { name: "INFO request", data: []byte("INFO sip:alice@example.com SIP/2.0\r\n"), expected: true, }, { name: "NOTIFY request", data: []byte("NOTIFY sip:alice@example.com SIP/2.0\r\n"), expected: true, }, { name: "SUBSCRIBE request", data: []byte("SUBSCRIBE sip:alice@example.com SIP/2.0\r\n"), expected: true, }, { name: "MESSAGE request", data: []byte("MESSAGE sip:alice@example.com SIP/2.0\r\n"), expected: true, }, // Case insensitivity { name: "lowercase register", data: []byte("register sip:example.com SIP/2.0\r\n"), expected: true, }, { name: "mixed case INVITE", data: []byte("Invite sip:alice@example.com SIP/2.0\r\n"), expected: true, }, // SIP responses { name: "SIP 200 OK response", data: []byte("SIP/2.0 200 OK\r\n"), expected: true, }, { name: "SIP 100 Trying response", data: []byte("SIP/2.0 100 Trying\r\n"), expected: true, }, { name: "SIP 180 Ringing response", data: []byte("SIP/2.0 180 Ringing\r\n"), expected: true, }, { name: "SIP 401 Unauthorized response", data: []byte("SIP/2.0 401 Unauthorized\r\n"), expected: true, }, { name: "SIP 486 Busy Here response", data: []byte("SIP/2.0 486 Busy Here\r\n"), expected: true, }, // Non-SIP traffic - MUST NOT match (should be passed through or rejected elsewhere) { name: "HTTP GET request", data: []byte("GET / HTTP/1.1\r\n"), expected: false, }, { name: "HTTP POST request", data: []byte("POST /api HTTP/1.1\r\n"), expected: false, }, { name: "SMTP EHLO", data: []byte("EHLO mail.example.com\r\n"), expected: false, }, { name: "random binary data", data: []byte{0x00, 0x01, 0x02, 0x03, 0x04}, expected: false, }, { name: "RTP-like packet", data: []byte{0x80, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { matches := m.methodRegex.Match(tt.data) || bytes.HasPrefix(tt.data, []byte("SIP/2.0")) if matches != tt.expected { t.Errorf("SIP pattern match for %q: got %v, want %v", tt.name, matches, tt.expected) } }) } } // TestMatcherDefaultMethods verifies the matcher provisions with correct default methods func TestMatcherDefaultMethods(t *testing.T) { m := provisionMatcherForTest(nil) expectedMethods := []string{"REGISTER", "INVITE", "OPTIONS", "ACK", "BYE", "CANCEL", "INFO", "NOTIFY", "SUBSCRIBE", "MESSAGE"} if len(m.Methods) != len(expectedMethods) { t.Errorf("Default methods count: got %d, want %d", len(m.Methods), len(expectedMethods)) } for _, method := range expectedMethods { found := false for _, m := range m.Methods { if m == method { found = true break } } if !found { t.Errorf("Expected method %s not in default methods", method) } } } // TestMatcherCustomMethods verifies custom method configuration works func TestMatcherCustomMethods(t *testing.T) { m := provisionMatcherForTest([]string{"REGISTER", "INVITE"}) // Should match REGISTER if !m.methodRegex.Match([]byte("REGISTER sip:example.com SIP/2.0\r\n")) { t.Error("Should match REGISTER when configured") } // Should match INVITE if !m.methodRegex.Match([]byte("INVITE sip:alice@example.com SIP/2.0\r\n")) { t.Error("Should match INVITE when configured") } // Should NOT match OPTIONS (not in our custom list) if m.methodRegex.Match([]byte("OPTIONS sip:example.com SIP/2.0\r\n")) { t.Error("Should NOT match OPTIONS when not in custom methods") } } // ============================================================================= // Suspicious Pattern Detection Tests // ============================================================================= func TestDetectSuspiciousPattern(t *testing.T) { tests := []struct { name string data []byte expectDetection bool expectedPattern string }{ // Known attack tools - MUST be detected { name: "SIPVicious User-Agent", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: friendly-scanner\r\n"), expectDetection: true, expectedPattern: "friendly-scanner", }, { name: "SIPVicious lowercase", data: []byte("OPTIONS sip:example.com SIP/2.0\r\nUser-Agent: sipvicious\r\n"), expectDetection: true, expectedPattern: "sipvicious", }, { name: "sipcli scanner", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: sipcli/1.0\r\n"), expectDetection: true, expectedPattern: "sipcli", }, { name: "sipsak tool", data: []byte("OPTIONS sip:example.com SIP/2.0\r\nUser-Agent: sipsak 0.9.7\r\n"), expectDetection: true, expectedPattern: "sipsak", }, { name: "VoIPBuster", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: voipbuster\r\n"), expectDetection: true, expectedPattern: "voipbuster", }, { name: "sundayddr scanner", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: sundayddr\r\n"), expectDetection: true, expectedPattern: "sundayddr", }, { name: "iwar dialer", data: []byte("INVITE sip:alice@example.com SIP/2.0\r\nUser-Agent: iwar/0.1\r\n"), expectDetection: true, expectedPattern: "iwar", }, // Common enumeration patterns { name: "test extension 100", data: []byte("REGISTER sip:100@example.com SIP/2.0\r\n"), expectDetection: true, expectedPattern: "test-extension-100", }, { name: "test extension 1000", data: []byte("REGISTER sip:1000@example.com SIP/2.0\r\n"), expectDetection: true, expectedPattern: "test-extension-1000", }, { name: "null user probe", data: []byte("REGISTER sip:@example.com SIP/2.0\r\n"), expectDetection: true, expectedPattern: "null-user", }, { name: "anonymous caller", data: []byte("INVITE sip:bob@example.com SIP/2.0\r\nFrom: \r\n"), expectDetection: true, expectedPattern: "anonymous", }, // LEGITIMATE traffic - MUST NOT be detected as suspicious { name: "Zoiper softphone", // Zoiper is a legitimate softphone - pattern removed to avoid false positives data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Zoiper rv2.0.18\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Linphone client", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Linphone/4.5.0\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Asterisk PBX", // The "asterisk pbx" pattern was removed as it caused false positives // Legitimate Asterisk servers now pass through correctly data: []byte("INVITE sip:alice@example.com SIP/2.0\r\nUser-Agent: Asterisk PBX 18.0\r\n"), expectDetection: false, expectedPattern: "", }, { name: "FreeSWITCH", data: []byte("INVITE sip:bob@example.com SIP/2.0\r\nUser-Agent: FreeSWITCH\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Grandstream phone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Grandstream GXP2170 1.0.11.10\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Polycom phone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: PolycomVVX-VVX_410-UA/5.9.3\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Yealink phone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Yealink SIP-T46S 66.86.0.15\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Cisco phone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Cisco-SIPIPCommunicator/9.1.1\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Avaya phone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: Avaya one-X Communicator\r\n"), expectDetection: false, expectedPattern: "", }, { name: "3CX Softphone", data: []byte("REGISTER sip:example.com SIP/2.0\r\nUser-Agent: 3CXPhone 6.0\r\n"), expectDetection: false, expectedPattern: "", }, { name: "Twilio gateway", data: []byte("INVITE sip:+15551234567@example.com SIP/2.0\r\nUser-Agent: twilio-client/2.0\r\n"), expectDetection: false, expectedPattern: "", }, { name: "regular extension 5001", data: []byte("REGISTER sip:5001@example.com SIP/2.0\r\nUser-Agent: Linphone\r\n"), expectDetection: false, expectedPattern: "", }, { name: "regular extension 1234", data: []byte("INVITE sip:1234@pbx.local SIP/2.0\r\n"), expectDetection: false, expectedPattern: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pattern := detectSuspiciousPattern(tt.data) detected := pattern != "" if detected != tt.expectDetection { t.Errorf("Detection for %q: got detected=%v, want detected=%v (pattern=%q)", tt.name, detected, tt.expectDetection, pattern) } if tt.expectDetection && pattern != tt.expectedPattern { t.Errorf("Pattern for %q: got %q, want %q", tt.name, pattern, tt.expectedPattern) } }) } } // TestLegacyIsSuspiciousSIP verifies the legacy wrapper function func TestLegacyIsSuspiciousSIP(t *testing.T) { // Suspicious - should return true if !isSuspiciousSIP([]byte("User-Agent: friendly-scanner")) { t.Error("Should detect friendly-scanner as suspicious") } // Not suspicious - should return false if isSuspiciousSIP([]byte("User-Agent: Linphone/4.5.0")) { t.Error("Should NOT detect Linphone as suspicious") } } // ============================================================================= // Complete SIP Message Tests - Real-world SIP traffic patterns // ============================================================================= func TestLegitimateREGISTERMessage(t *testing.T) { // A complete, legitimate REGISTER message from a typical SIP phone // Note: Using proper CRLF line endings as per SIP RFC 3261 msg := []byte("REGISTER sip:1001@pbx.example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK-524287-1-0\r\n" + "Max-Forwards: 70\r\n" + "From: \"John Smith\" ;tag=1\r\n" + "To: \r\n" + "Call-ID: 1-1234@192.168.1.100\r\n" + "CSeq: 1 REGISTER\r\n" + "Contact: \r\n" + "User-Agent: Yealink SIP-T46S 66.86.0.15\r\n" + "Expires: 3600\r\n" + "Allow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO\r\n" + "Content-Length: 0\r\n" + "\r\n") // Should NOT be detected as suspicious pattern := detectSuspiciousPattern(msg) if pattern != "" { t.Errorf("Legitimate REGISTER should NOT be flagged as suspicious, got pattern: %s", pattern) } // SIP method extraction should work method := ExtractSIPMethod(msg) if method != MethodREGISTER { t.Errorf("Method extraction: got %v, want REGISTER", method) } // Extension extraction should work ext := ExtractTargetExtension(msg) if ext != "1001" { t.Errorf("Extension extraction: got %q, want 1001", ext) } } func TestLegitimateINVITEMessage(t *testing.T) { // A complete, legitimate INVITE message for a call msg := []byte(`INVITE sip:5002@pbx.example.com SIP/2.0 Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK-1234567 Max-Forwards: 70 From: "Alice" ;tag=abc123 To: Call-ID: call-8888@192.168.1.100 CSeq: 1 INVITE Contact: User-Agent: Grandstream GXP2170 1.0.11.10 Allow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO Content-Type: application/sdp Content-Length: 260 v=0 o=- 1234567890 1234567890 IN IP4 192.168.1.100 s=- c=IN IP4 192.168.1.100 t=0 0 m=audio 10000 RTP/AVP 0 8 101 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=fmtp:101 0-16 `) // Should NOT be detected as suspicious pattern := detectSuspiciousPattern(msg) if pattern != "" { t.Errorf("Legitimate INVITE should NOT be flagged as suspicious, got pattern: %s", pattern) } // SIP method extraction should work method := ExtractSIPMethod(msg) if method != MethodINVITE { t.Errorf("Method extraction: got %v, want INVITE", method) } // Extension extraction should work ext := ExtractTargetExtension(msg) if ext != "5002" { t.Errorf("Extension extraction: got %q, want 5002", ext) } } func TestLegitimateOPTIONSKeepAlive(t *testing.T) { // OPTIONS is commonly used for NAT keep-alive msg := []byte(`OPTIONS sip:pbx.example.com SIP/2.0 Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK-ping-001 Max-Forwards: 70 From: ;tag=keepalive To: Call-ID: keepalive-12345@192.168.1.100 CSeq: 100 OPTIONS User-Agent: Polycom/5.9.3 Accept: application/sdp Content-Length: 0 `) // Should NOT be detected as suspicious pattern := detectSuspiciousPattern(msg) if pattern != "" { t.Errorf("Legitimate OPTIONS keep-alive should NOT be flagged, got pattern: %s", pattern) } method := ExtractSIPMethod(msg) if method != MethodOPTIONS { t.Errorf("Method extraction: got %v, want OPTIONS", method) } } func TestLegitimate200OKResponse(t *testing.T) { // A 200 OK response to REGISTER msg := []byte(`SIP/2.0 200 OK Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK-524287-1-0;received=192.168.1.100 From: "John Smith" ;tag=1 To: ;tag=as1234 Call-ID: 1-1234@192.168.1.100 CSeq: 1 REGISTER Contact: ;expires=3600 Date: Mon, 01 Jan 2024 12:00:00 GMT Server: Asterisk PBX 18.0 Content-Length: 0 `) // Should NOT be detected as suspicious (Server: Asterisk is NOT "asterisk pbx" scanner signature) pattern := detectSuspiciousPattern(msg) if pattern != "" && pattern != "asterisk-pbx-scanner" { t.Errorf("200 OK response should NOT be flagged as suspicious, got pattern: %s", pattern) } } // ============================================================================= // Helper Function - min() // ============================================================================= func TestMinFunction(t *testing.T) { tests := []struct { a, b, expected int }{ {1, 2, 1}, {2, 1, 1}, {5, 5, 5}, {0, 10, 0}, {-1, 1, -1}, } for _, tt := range tests { result := min(tt.a, tt.b) if result != tt.expected { t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected) } } } // ============================================================================= // SIPHandler Module Info Test // ============================================================================= func TestSIPHandlerModuleInfo(t *testing.T) { h := SIPHandler{} info := h.CaddyModule() if info.ID != "layer4.handlers.sip_guardian" { t.Errorf("Module ID: got %q, want %q", info.ID, "layer4.handlers.sip_guardian") } if info.New == nil { t.Error("Module New function should not be nil") } // Verify New() returns correct type newModule := info.New() if _, ok := newModule.(*SIPHandler); !ok { t.Error("New() should return *SIPHandler") } } func TestSIPMatcherModuleInfo(t *testing.T) { m := SIPMatcher{} info := m.CaddyModule() if info.ID != "layer4.matchers.sip" { t.Errorf("Module ID: got %q, want %q", info.ID, "layer4.matchers.sip") } if info.New == nil { t.Error("Module New function should not be nil") } // Verify New() returns correct type newModule := info.New() if _, ok := newModule.(*SIPMatcher); !ok { t.Error("New() should return *SIPMatcher") } }