diff --git a/api/openapi.yaml b/api/openapi.yaml index 6762439..a44a588 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -277,9 +277,18 @@ components: items: $ref: '#/components/schemas/DNSRecord' blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + listed: false + - rbl: "bl.spamcop.net" + listed: false raw_headers: type: string description: Raw email headers @@ -498,14 +507,9 @@ components: BlacklistCheck: type: object required: - - ip - rbl - listed properties: - ip: - type: string - description: IP address checked - example: "192.0.2.1" rbl: type: string description: RBL/DNSBL name diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 2c7833b..f13e681 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -68,14 +68,14 @@ func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { // RBLResults represents the results of RBL checks type RBLResults struct { - Checks []RBLCheck + Checks map[string][]RBLCheck // Map of IP -> list of RBL checks for that IP IPsChecked []string ListedCount int } // RBLCheck represents a single RBL check result +// Note: IP is not included here as it's used as the map key in the API type RBLCheck struct { - IP string RBL string Listed bool Response string @@ -84,7 +84,9 @@ type RBLCheck struct { // CheckEmail checks all IPs found in the email headers against RBLs func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{} + results := &RBLResults{ + Checks: make(map[string][]RBLCheck), + } // Extract IPs from Received headers ips := r.extractIPs(email) @@ -98,7 +100,7 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { for _, ip := range ips { for _, rbl := range r.RBLs { check := r.checkIP(ip, rbl) - results.Checks = append(results.Checks, check) + results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ } @@ -179,7 +181,6 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { // checkIP checks a single IP against a single RBL func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { check := RBLCheck{ - IP: ip, RBL: rbl, } @@ -285,14 +286,16 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { checks = append(checks, summaryCheck) // Create individual checks for each listing and RBL errors - for _, check := range results.Checks { - if check.Listed { - detailCheck := r.generateListingCheck(&check) - checks = append(checks, detailCheck) - } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { - // Generate info check for RBL errors - detailCheck := r.generateRBLErrorCheck(&check) - checks = append(checks, detailCheck) + for ip, rblChecks := range results.Checks { + for _, check := range rblChecks { + if check.Listed { + detailCheck := r.generateListingCheck(ip, &check) + checks = append(checks, detailCheck) + } else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") { + // Generate info check for RBL errors + detailCheck := r.generateRBLErrorCheck(ip, &check) + checks = append(checks, detailCheck) + } } } @@ -310,7 +313,11 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { check.Score = score check.Grade = ScoreToCheckGrade(score) - totalChecks := len(results.Checks) + // Calculate total checks across all IPs + totalChecks := 0 + for _, rblChecks := range results.Checks { + totalChecks += len(rblChecks) + } listedCount := results.ListedCount if listedCount == 0 { @@ -345,7 +352,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { } // generateListingCheck creates a check for a specific RBL listing -func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { +func (r *RBLChecker) generateListingCheck(ip string, rblCheck *RBLCheck) api.Check { check := api.Check{ Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), @@ -354,7 +361,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Grade: ScoreToCheckGrade(0), } - check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) + check.Message = fmt.Sprintf("IP %s is listed on %s", ip, rblCheck.RBL) // Determine severity based on which RBL if strings.Contains(rblCheck.RBL, "spamhaus") { @@ -381,7 +388,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { } // generateRBLErrorCheck creates an info-level check for RBL operational errors -func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { +func (r *RBLChecker) generateRBLErrorCheck(ip string, rblCheck *RBLCheck) api.Check { check := api.Check{ Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), @@ -391,7 +398,7 @@ func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.Check { Severity: api.PtrTo(api.CheckSeverityInfo), } - check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, rblCheck.IP) + check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, ip) advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response) check.Advice = &advice @@ -406,13 +413,14 @@ func (r *RBLChecker) generateRBLErrorCheck(rblCheck *RBLCheck) api.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 + for ip, rblChecks := range results.Checks { + for _, check := range rblChecks { + if check.Listed { + listedIPs = append(listedIPs, ip) + break // Only add the IP once + } } } @@ -423,9 +431,11 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { 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) + if rblChecks, exists := results.Checks[ip]; exists { + for _, check := range rblChecks { + if check.Listed { + rbls = append(rbls, check.RBL) + } } } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index c2bac11..2bd5c35 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -347,7 +347,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, - Checks: make([]RBLCheck, 6), // 6 default RBLs + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), // 6 default RBLs + }, }, expectedStatus: api.CheckStatusPass, expectedScore: 200, @@ -357,7 +359,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusWarn, expectedScore: 100, @@ -367,7 +371,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusWarn, expectedScore: 50, @@ -377,7 +383,9 @@ func TestGenerateSummaryCheck(t *testing.T) { results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, - Checks: make([]RBLCheck, 6), + Checks: map[string][]RBLCheck{ + "198.51.100.1": make([]RBLCheck, 6), + }, }, expectedStatus: api.CheckStatusFail, expectedScore: 0, @@ -413,7 +421,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "Spamhaus listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true, Response: "127.0.0.2", @@ -424,7 +431,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "SpamCop listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true, Response: "127.0.0.2", @@ -435,7 +441,6 @@ func TestGenerateListingCheck(t *testing.T) { { name: "Other RBL listing", rblCheck: &RBLCheck{ - IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: true, Response: "127.0.0.2", @@ -449,7 +454,7 @@ func TestGenerateListingCheck(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - check := checker.generateListingCheck(tt.rblCheck) + check := checker.generateListingCheck("198.51.100.1", tt.rblCheck) if check.Status != tt.expectedStatus { t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) @@ -490,9 +495,11 @@ func TestGenerateRBLChecks(t *testing.T) { 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}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: false}, + {RBL: "bl.spamcop.net", Listed: false}, + }, }, }, minChecks: 1, // Summary check only @@ -502,10 +509,12 @@ func TestGenerateRBLChecks(t *testing.T) { 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}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + {RBL: "dnsbl.sorbs.net", Listed: false}, + }, }, }, minChecks: 3, // Summary + 2 listing checks @@ -534,12 +543,18 @@ func TestGenerateRBLChecks(t *testing.T) { 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}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + }, + "198.51.100.2": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: false}, + }, + "198.51.100.3": { + {RBL: "zen.spamhaus.org", Listed: false}, + }, }, } @@ -556,11 +571,15 @@ func TestGetUniqueListedIPs(t *testing.T) { 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}, + Checks: map[string][]RBLCheck{ + "198.51.100.1": { + {RBL: "zen.spamhaus.org", Listed: true}, + {RBL: "bl.spamcop.net", Listed: true}, + {RBL: "dnsbl.sorbs.net", Listed: false}, + }, + "198.51.100.2": { + {RBL: "zen.spamhaus.org", Listed: true}, + }, }, } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6d5522b..78a0b5e 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -190,21 +190,29 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } } - // Add blacklist checks + // Add blacklist checks as a map of IP -> array of BlacklistCheck if results.RBL != nil && len(results.RBL.Checks) > 0 { - blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) - for _, check := range results.RBL.Checks { - blCheck := api.BlacklistCheck{ - Ip: check.IP, - Rbl: check.RBL, - Listed: check.Listed, + blacklistMap := make(map[string][]api.BlacklistCheck) + + // Convert internal RBL checks to API format + for ip, rblChecks := range results.RBL.Checks { + apiChecks := make([]api.BlacklistCheck, 0, len(rblChecks)) + for _, check := range rblChecks { + blCheck := api.BlacklistCheck{ + Rbl: check.RBL, + Listed: check.Listed, + } + if check.Response != "" { + blCheck.Response = &check.Response + } + apiChecks = append(apiChecks, blCheck) } - if check.Response != "" { - blCheck.Response = &check.Response - } - blacklistChecks = append(blacklistChecks, blCheck) + blacklistMap[ip] = apiChecks + } + + if len(blacklistMap) > 0 { + report.Blacklists = &blacklistMap } - report.Blacklists = &blacklistChecks } // Add raw headers