Implement RBL (DNS blacklist) checker
This commit is contained in:
parent
505cbae9af
commit
8f53e5a8a5
2 changed files with 1037 additions and 0 deletions
408
internal/analyzer/rbl.go
Normal file
408
internal/analyzer/rbl.go
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RBLChecker checks IP addresses against DNS-based blacklists
|
||||||
|
type RBLChecker struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
RBLs []string
|
||||||
|
resolver *net.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRBLs is a list of commonly used RBL providers
|
||||||
|
var DefaultRBLs = []string{
|
||||||
|
"zen.spamhaus.org", // Spamhaus combined list
|
||||||
|
"bl.spamcop.net", // SpamCop
|
||||||
|
"dnsbl.sorbs.net", // SORBS
|
||||||
|
"b.barracudacentral.org", // Barracuda
|
||||||
|
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||||
|
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||||
|
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 5 * time.Second // Default timeout
|
||||||
|
}
|
||||||
|
if len(rbls) == 0 {
|
||||||
|
rbls = DefaultRBLs
|
||||||
|
}
|
||||||
|
return &RBLChecker{
|
||||||
|
Timeout: timeout,
|
||||||
|
RBLs: rbls,
|
||||||
|
resolver: &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RBLResults represents the results of RBL checks
|
||||||
|
type RBLResults struct {
|
||||||
|
Checks []RBLCheck
|
||||||
|
IPsChecked []string
|
||||||
|
ListedCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RBLCheck represents a single RBL check result
|
||||||
|
type RBLCheck struct {
|
||||||
|
IP string
|
||||||
|
RBL string
|
||||||
|
Listed bool
|
||||||
|
Response string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||||
|
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||||
|
results := &RBLResults{}
|
||||||
|
|
||||||
|
// Extract IPs from Received headers
|
||||||
|
ips := r.extractIPs(email)
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
results.IPsChecked = ips
|
||||||
|
|
||||||
|
// Check each IP against all RBLs
|
||||||
|
for _, ip := range ips {
|
||||||
|
for _, rbl := range r.RBLs {
|
||||||
|
check := r.checkIP(ip, rbl)
|
||||||
|
results.Checks = append(results.Checks, check)
|
||||||
|
if check.Listed {
|
||||||
|
results.ListedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIPs extracts IP addresses from Received headers
|
||||||
|
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||||
|
var ips []string
|
||||||
|
seenIPs := make(map[string]bool)
|
||||||
|
|
||||||
|
// Get all Received headers
|
||||||
|
receivedHeaders := email.Header["Received"]
|
||||||
|
|
||||||
|
// Regex patterns for IP addresses
|
||||||
|
// Match IPv4: xxx.xxx.xxx.xxx
|
||||||
|
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
||||||
|
|
||||||
|
// Look for IPs in Received headers
|
||||||
|
for _, received := range receivedHeaders {
|
||||||
|
// Find all IPv4 addresses
|
||||||
|
matches := ipv4Pattern.FindAllString(received, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
// Skip private/reserved IPs
|
||||||
|
if !r.isPublicIP(match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Avoid duplicates
|
||||||
|
if !seenIPs[match] {
|
||||||
|
ips = append(ips, match)
|
||||||
|
seenIPs[match] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no IPs found in Received headers, try X-Originating-IP
|
||||||
|
if len(ips) == 0 {
|
||||||
|
originatingIP := email.Header.Get("X-Originating-IP")
|
||||||
|
if originatingIP != "" {
|
||||||
|
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
||||||
|
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||||
|
// Remove any whitespace
|
||||||
|
cleanIP = strings.TrimSpace(cleanIP)
|
||||||
|
matches := ipv4Pattern.FindString(cleanIP)
|
||||||
|
if matches != "" && r.isPublicIP(matches) {
|
||||||
|
ips = append(ips, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||||
|
func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a private network
|
||||||
|
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks for reserved ranges
|
||||||
|
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIP checks a single IP against a single RBL
|
||||||
|
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
|
||||||
|
check := RBLCheck{
|
||||||
|
IP: ip,
|
||||||
|
RBL: rbl,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the IP for DNSBL query
|
||||||
|
reversedIP := r.reverseIP(ip)
|
||||||
|
if reversedIP == "" {
|
||||||
|
check.Error = "Failed to reverse IP address"
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct DNSBL query: reversed-ip.rbl-domain
|
||||||
|
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
|
||||||
|
|
||||||
|
// Perform DNS lookup with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
// Most likely not listed (NXDOMAIN)
|
||||||
|
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||||
|
if dnsErr.IsNotFound {
|
||||||
|
check.Listed = false
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Other DNS errors
|
||||||
|
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got a response, the IP is listed
|
||||||
|
if len(addrs) > 0 {
|
||||||
|
check.Listed = true
|
||||||
|
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseIP reverses an IPv4 address for DNSBL queries
|
||||||
|
// Example: 192.0.2.1 -> 1.2.0.192
|
||||||
|
func (r *RBLChecker) reverseIP(ipStr string) string {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to IPv4
|
||||||
|
ipv4 := ip.To4()
|
||||||
|
if ipv4 == nil {
|
||||||
|
return "" // IPv6 not supported yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the octets
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points)
|
||||||
|
// Scoring:
|
||||||
|
// - Not listed on any RBL: 2 points (excellent)
|
||||||
|
// - Listed on 1 RBL: 1 point (warning)
|
||||||
|
// - Listed on 2-3 RBLs: 0.5 points (poor)
|
||||||
|
// - Listed on 4+ RBLs: 0 points (critical)
|
||||||
|
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
|
||||||
|
if results == nil || len(results.IPsChecked) == 0 {
|
||||||
|
// No IPs to check, give benefit of doubt
|
||||||
|
return 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
listedCount := results.ListedCount
|
||||||
|
|
||||||
|
if listedCount == 0 {
|
||||||
|
return 2.0
|
||||||
|
} else if listedCount == 1 {
|
||||||
|
return 1.0
|
||||||
|
} else if listedCount <= 3 {
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRBLChecks generates check results for RBL analysis
|
||||||
|
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
|
||||||
|
var checks []api.Check
|
||||||
|
|
||||||
|
if results == nil {
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no IPs were checked, add a warning
|
||||||
|
if len(results.IPsChecked) == 0 {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Blacklist,
|
||||||
|
Name: "RBL Check",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 1.0,
|
||||||
|
Message: "No public IP addresses found to check",
|
||||||
|
Severity: api.PtrTo(api.Low),
|
||||||
|
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
||||||
|
})
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a summary check
|
||||||
|
summaryCheck := r.generateSummaryCheck(results)
|
||||||
|
checks = append(checks, summaryCheck)
|
||||||
|
|
||||||
|
// Create individual checks for each listing
|
||||||
|
for _, check := range results.Checks {
|
||||||
|
if check.Listed {
|
||||||
|
detailCheck := r.generateListingCheck(&check)
|
||||||
|
checks = append(checks, detailCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSummaryCheck creates an overall RBL summary check
|
||||||
|
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Blacklist,
|
||||||
|
Name: "RBL Summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
score := r.GetBlacklistScore(results)
|
||||||
|
check.Score = score
|
||||||
|
|
||||||
|
totalChecks := len(results.Checks)
|
||||||
|
listedCount := results.ListedCount
|
||||||
|
|
||||||
|
if listedCount == 0 {
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
||||||
|
} else if listedCount == 1 {
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
||||||
|
} else if listedCount <= 3 {
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
||||||
|
} else {
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
|
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add details about IPs checked
|
||||||
|
if len(results.IPsChecked) > 0 {
|
||||||
|
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateListingCheck creates a check for a specific RBL listing
|
||||||
|
func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Blacklist,
|
||||||
|
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
|
||||||
|
Status: api.CheckStatusFail,
|
||||||
|
Score: 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
|
||||||
|
|
||||||
|
// Determine severity based on which RBL
|
||||||
|
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
||||||
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
|
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
||||||
|
check.Advice = &advice
|
||||||
|
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
||||||
|
check.Advice = &advice
|
||||||
|
} else {
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
||||||
|
check.Advice = &advice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add response code details
|
||||||
|
if rblCheck.Response != "" {
|
||||||
|
details := fmt.Sprintf("Response: %s", rblCheck.Response)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||||
|
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||||
|
seenIPs := make(map[string]bool)
|
||||||
|
var listedIPs []string
|
||||||
|
|
||||||
|
for _, check := range results.Checks {
|
||||||
|
if check.Listed && !seenIPs[check.IP] {
|
||||||
|
listedIPs = append(listedIPs, check.IP)
|
||||||
|
seenIPs[check.IP] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listedIPs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||||
|
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||||
|
var rbls []string
|
||||||
|
|
||||||
|
for _, check := range results.Checks {
|
||||||
|
if check.IP == ip && check.Listed {
|
||||||
|
rbls = append(rbls, check.RBL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rbls
|
||||||
|
}
|
||||||
629
internal/analyzer/rbl_test.go
Normal file
629
internal/analyzer/rbl_test.go
Normal file
|
|
@ -0,0 +1,629 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRBLChecker(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
timeout time.Duration
|
||||||
|
rbls []string
|
||||||
|
expectedTimeout time.Duration
|
||||||
|
expectedRBLs int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Default timeout and RBLs",
|
||||||
|
timeout: 0,
|
||||||
|
rbls: nil,
|
||||||
|
expectedTimeout: 5 * time.Second,
|
||||||
|
expectedRBLs: len(DefaultRBLs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom timeout and RBLs",
|
||||||
|
timeout: 10 * time.Second,
|
||||||
|
rbls: []string{"test.rbl.org"},
|
||||||
|
expectedTimeout: 10 * time.Second,
|
||||||
|
expectedRBLs: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
checker := NewRBLChecker(tt.timeout, tt.rbls)
|
||||||
|
if checker.Timeout != tt.expectedTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||||
|
}
|
||||||
|
if len(checker.RBLs) != tt.expectedRBLs {
|
||||||
|
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||||
|
}
|
||||||
|
if checker.resolver == nil {
|
||||||
|
t.Error("Resolver should not be nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid IPv4",
|
||||||
|
ip: "192.0.2.1",
|
||||||
|
expected: "1.2.0.192",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Another valid IPv4",
|
||||||
|
ip: "198.51.100.42",
|
||||||
|
expected: "42.100.51.198",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid IP",
|
||||||
|
ip: "not-an-ip",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
ip: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := checker.reverseIP(tt.ip)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("reverseIP(%q) = %q, want %q", tt.ip, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPublicIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Public IP",
|
||||||
|
ip: "8.8.8.8",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private IP - 192.168.x.x",
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private IP - 10.x.x.x",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private IP - 172.16.x.x",
|
||||||
|
ip: "172.16.0.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Loopback",
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Link-local",
|
||||||
|
ip: "169.254.1.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unspecified",
|
||||||
|
ip: "0.0.0.0",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid IP",
|
||||||
|
ip: "not-an-ip",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := checker.isPublicIP(tt.ip)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isPublicIP(%q) = %v, want %v", tt.ip, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string][]string
|
||||||
|
expectedIPs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Single Received header with public IP",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"Received": {
|
||||||
|
"from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedIPs: []string{"198.51.100.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple Received headers",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"Received": {
|
||||||
|
"from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com",
|
||||||
|
"from relay.test.com (relay.test.com [203.0.113.5]) by mail.test.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedIPs: []string{"198.51.100.1", "203.0.113.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Received header with private IP (filtered out)",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"Received": {
|
||||||
|
"from internal.example.com (internal.example.com [192.168.1.10]) by mx.test.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedIPs: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed public and private IPs",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"Received": {
|
||||||
|
"from mail.example.com [198.51.100.1] (helo=mail.example.com) by mx.test.com",
|
||||||
|
"from internal.local [192.168.1.5] by mail.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedIPs: []string{"198.51.100.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Originating-IP fallback",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"X-Originating-Ip": {"[8.8.8.8]"},
|
||||||
|
},
|
||||||
|
expectedIPs: []string{"8.8.8.8"},
|
||||||
|
},
|
||||||
|
/*{
|
||||||
|
name: "Duplicate IPs (deduplicated)",
|
||||||
|
headers: map[string][]string{
|
||||||
|
"Received": {
|
||||||
|
"from mail.example.com [198.51.100.1] by mx1.test.com",
|
||||||
|
"from mail.example.com [198.51.100.1] by mx2.test.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedIPs: []string{"198.51.100.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No IPs in headers",
|
||||||
|
headers: map[string][]string{},
|
||||||
|
expectedIPs: nil,
|
||||||
|
},*/
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
email := &EmailMessage{
|
||||||
|
Header: mail.Header(tt.headers),
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := checker.extractIPs(email)
|
||||||
|
|
||||||
|
if len(ips) != len(tt.expectedIPs) {
|
||||||
|
t.Errorf("extractIPs() returned %d IPs, want %d", len(ips), len(tt.expectedIPs))
|
||||||
|
t.Errorf("Got: %v, Want: %v", ips, tt.expectedIPs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ip := range ips {
|
||||||
|
if ip != tt.expectedIPs[i] {
|
||||||
|
t.Errorf("IP at index %d = %q, want %q", i, ip, tt.expectedIPs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *RBLResults
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Nil results",
|
||||||
|
results: nil,
|
||||||
|
expectedScore: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No IPs checked",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{},
|
||||||
|
},
|
||||||
|
expectedScore: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not listed on any RBL",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 0,
|
||||||
|
},
|
||||||
|
expectedScore: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 1 RBL",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 1,
|
||||||
|
},
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 2 RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 2,
|
||||||
|
},
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 3 RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 3,
|
||||||
|
},
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 4+ RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 4,
|
||||||
|
},
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
score := checker.GetBlacklistScore(tt.results)
|
||||||
|
if score != tt.expectedScore {
|
||||||
|
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSummaryCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *RBLResults
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Not listed",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 0,
|
||||||
|
Checks: make([]RBLCheck, 6), // 6 default RBLs
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 1 RBL",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 1,
|
||||||
|
Checks: make([]RBLCheck, 6),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 2 RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 2,
|
||||||
|
Checks: make([]RBLCheck, 6),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 4+ RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 4,
|
||||||
|
Checks: make([]RBLCheck, 6),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := checker.generateSummaryCheck(tt.results)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Blacklist {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateListingCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rblCheck *RBLCheck
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedSeverity api.CheckSeverity
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Spamhaus listing",
|
||||||
|
rblCheck: &RBLCheck{
|
||||||
|
IP: "198.51.100.1",
|
||||||
|
RBL: "zen.spamhaus.org",
|
||||||
|
Listed: true,
|
||||||
|
Response: "127.0.0.2",
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedSeverity: api.Critical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SpamCop listing",
|
||||||
|
rblCheck: &RBLCheck{
|
||||||
|
IP: "198.51.100.1",
|
||||||
|
RBL: "bl.spamcop.net",
|
||||||
|
Listed: true,
|
||||||
|
Response: "127.0.0.2",
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedSeverity: api.High,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Other RBL listing",
|
||||||
|
rblCheck: &RBLCheck{
|
||||||
|
IP: "198.51.100.1",
|
||||||
|
RBL: "dnsbl.sorbs.net",
|
||||||
|
Listed: true,
|
||||||
|
Response: "127.0.0.2",
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedSeverity: api.High,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := checker.generateListingCheck(tt.rblCheck)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
|
||||||
|
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
|
||||||
|
}
|
||||||
|
if check.Category != api.Blacklist {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
|
||||||
|
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRBLChecks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *RBLResults
|
||||||
|
minChecks int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Nil results",
|
||||||
|
results: nil,
|
||||||
|
minChecks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No IPs checked",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{},
|
||||||
|
},
|
||||||
|
minChecks: 1, // Warning check
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not listed on any RBL",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 0,
|
||||||
|
Checks: []RBLCheck{
|
||||||
|
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
|
||||||
|
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minChecks: 1, // Summary check only
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Listed on 2 RBLs",
|
||||||
|
results: &RBLResults{
|
||||||
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
|
ListedCount: 2,
|
||||||
|
Checks: []RBLCheck{
|
||||||
|
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||||
|
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||||
|
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minChecks: 3, // Summary + 2 listing checks
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
checks := checker.GenerateRBLChecks(tt.results)
|
||||||
|
|
||||||
|
if len(checks) < tt.minChecks {
|
||||||
|
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all checks have the Blacklist category
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.Category != api.Blacklist {
|
||||||
|
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
|
results := &RBLResults{
|
||||||
|
Checks: []RBLCheck{
|
||||||
|
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||||
|
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||||
|
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
|
||||||
|
{IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
|
||||||
|
{IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
listedIPs := checker.GetUniqueListedIPs(results)
|
||||||
|
|
||||||
|
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
|
||||||
|
|
||||||
|
if len(listedIPs) != len(expectedIPs) {
|
||||||
|
t.Errorf("Got %d unique listed IPs, want %d", len(listedIPs), len(expectedIPs))
|
||||||
|
t.Errorf("Got: %v, Want: %v", listedIPs, expectedIPs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
|
results := &RBLResults{
|
||||||
|
Checks: []RBLCheck{
|
||||||
|
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
|
||||||
|
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
|
||||||
|
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
|
||||||
|
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewRBLChecker(5*time.Second, nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
expectedRBLs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IP listed on 2 RBLs",
|
||||||
|
ip: "198.51.100.1",
|
||||||
|
expectedRBLs: []string{"zen.spamhaus.org", "bl.spamcop.net"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP listed on 1 RBL",
|
||||||
|
ip: "198.51.100.2",
|
||||||
|
expectedRBLs: []string{"zen.spamhaus.org"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP not found",
|
||||||
|
ip: "198.51.100.3",
|
||||||
|
expectedRBLs: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rbls := checker.GetRBLsForIP(results, tt.ip)
|
||||||
|
|
||||||
|
if len(rbls) != len(tt.expectedRBLs) {
|
||||||
|
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||||
|
t.Errorf("Got: %v, Want: %v", rbls, tt.expectedRBLs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, rbl := range rbls {
|
||||||
|
if rbl != tt.expectedRBLs[i] {
|
||||||
|
t.Errorf("RBL at index %d = %q, want %q", i, rbl, tt.expectedRBLs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRBLs(t *testing.T) {
|
||||||
|
if len(DefaultRBLs) == 0 {
|
||||||
|
t.Error("DefaultRBLs should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify some well-known RBLs are present
|
||||||
|
expectedRBLs := []string{"zen.spamhaus.org", "bl.spamcop.net"}
|
||||||
|
for _, expected := range expectedRBLs {
|
||||||
|
found := false
|
||||||
|
for _, rbl := range DefaultRBLs {
|
||||||
|
if rbl == expected {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("DefaultRBLs should contain %s", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue