diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 2003c48..bc6ae38 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -22,9 +22,6 @@ package analyzer import ( - "fmt" - "regexp" - "slices" "strings" "git.happydns.org/happyDeliver/internal/api" @@ -144,399 +141,6 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, } } -// parseSPFResult parses SPF result from Authentication-Results -// Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`spf=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain - domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - email := matches[1] - // Extract domain from email - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) - - return result -} - -// parseDKIMResult parses DKIM result from Authentication-Results -// Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) - - return result -} - -// parseDMARCResult parses DMARC result from Authentication-Results -// Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`dmarc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.from) - domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) - - return result -} - -// parseBIMIResult parses BIMI result from Authentication-Results -// Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`bimi=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.selector or selector) - selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) - - return result -} - -// parseARCResult parses ARC result from Authentication-Results -// Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} - - // Extract result (pass, fail, none) - re := regexp.MustCompile(`arc=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.ARCResultResult(resultStr) - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) - - return result -} - -// parseIPRevResult parses IP reverse lookup result from Authentication-Results -// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) -func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { - result := &api.IPRevResult{} - - // Extract result (pass, fail, temperror, permerror, none) - re := regexp.MustCompile(`iprev=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.IPRevResultResult(resultStr) - } - - // Extract IP address (smtp.remote-ip or remote-ip) - ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) - if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { - ip := matches[1] - result.Ip = &ip - } - - // Extract hostname from parentheses - hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) - if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { - hostname := matches[1] - result.Hostname = &hostname - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) - - return result -} - -// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results -// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 -func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} - - // Extract result (pass, fail, etc.) - re := regexp.MustCompile(`x-google-dkim=(\w+)`) - if matches := re.FindStringSubmatch(part); len(matches) > 1 { - resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - though not always present in x-google-dkim - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) - - return result -} - -// parseARCHeaders parses ARC headers from email message -// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { - // Get all ARC-related headers - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - - // If no ARC headers present, return nil - if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { - return nil - } - - result := &api.ARCResult{ - Result: api.ARCResultResultNone, - } - - // Count the ARC chain length (number of sets) - chainLength := len(arcSeal) - result.ChainLength = &chainLength - - // Validate the ARC chain - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - result.ChainValid = &chainValid - - // Determine overall result - if chainLength == 0 { - result.Result = api.ARCResultResultNone - details := "No ARC chain present" - result.Details = &details - } else if !chainValid { - result.Result = api.ARCResultResultFail - details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) - result.Details = &details - } else { - result.Result = api.ARCResultResultPass - details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) - result.Details = &details - } - - return result -} - -// enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { - if arcResult == nil { - return - } - - // Get ARC headers - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - - // Set chain length if not already set - if arcResult.ChainLength == nil { - chainLength := len(arcSeal) - arcResult.ChainLength = &chainLength - } - - // Validate chain if not already validated - if arcResult.ChainValid == nil { - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - arcResult.ChainValid = &chainValid - } -} - -// validateARCChain validates the ARC chain for completeness -// Each instance should have all three headers with matching instance numbers -func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { - // All three header types should have the same count - if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { - return false - } - - if len(arcSeal) == 0 { - return true // No ARC chain is technically valid - } - - // Extract instance numbers from each header type - sealInstances := a.extractARCInstances(arcSeal) - sigInstances := a.extractARCInstances(arcMessageSig) - authInstances := a.extractARCInstances(arcAuthResults) - - // Check that all instance numbers match and are sequential starting from 1 - if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { - return false - } - - // Verify instances are sequential from 1 to N - for i := 1; i <= len(sealInstances); i++ { - if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { - return false - } - } - - return true -} - -// extractARCInstances extracts instance numbers from ARC headers -func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { - var instances []int - re := regexp.MustCompile(`i=(\d+)`) - - for _, header := range headers { - if matches := re.FindStringSubmatch(header); len(matches) > 1 { - var instance int - fmt.Sscanf(matches[1], "%d", &instance) - instances = append(instances, instance) - } - } - - return instances -} - -// pluralize returns "y" or "ies" based on count -func pluralize(count int) string { - if count == 1 { - return "y" - } - return "ies" -} - -// parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { - receivedSPF := email.Header.Get("Received-SPF") - if receivedSPF == "" { - return nil - } - - result := &api.AuthResult{} - - // Extract result (first word) - parts := strings.Fields(receivedSPF) - if len(parts) > 0 { - resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) - } - - result.Details = &receivedSPF - - // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) - if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - email := matches[1] - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - return result -} - -// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header -func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { - var results []api.AuthResult - - // Get all DKIM-Signature headers - dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] - for _, dkimHeader := range dkimHeaders { - result := api.AuthResult{ - Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone - } - - // Extract domain (d=) - domainRe := regexp.MustCompile(`d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (s=) - selectorRe := regexp.MustCompile(`s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - details := "DKIM signature present (verification status unknown)" - result.Details = &details - - results = append(results, result) - } - - return results -} - -// textprotoCanonical converts a header name to canonical form -func textprotoCanonical(s string) string { - // Simple implementation - capitalize each word - words := strings.Split(s, "-") - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) - } - } - return strings.Join(words, "-") -} - // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { @@ -547,79 +151,22 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe score := 0 // IPRev (15 points) - if results.Iprev != nil { - switch results.Iprev.Result { - case api.Pass: - score += 15 - default: // fail, temperror, permerror - score += 0 - } - } + score += 15 * a.calculateIPRevScore(results) / 100 // SPF (25 points) - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 25 - case api.AuthResultResultNeutral, api.AuthResultResultNone: - score += 12 - case api.AuthResultResultSoftfail: - score += 4 - default: // fail, temperror, permerror - score += 0 - } - } + score += 25 * a.calculateSPFScore(results) / 100 - // DKIM (25 points) - at least one passing signature - if results.Dkim != nil && len(*results.Dkim) > 0 { - hasPass := false - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - hasPass = true - break - } - } - if hasPass { - score += 25 - } else { - // Has DKIM signatures but none passed - score += 10 - } - } + // DKIM (25 points) + score += 25 * a.calculateDKIMScore(results) / 100 // X-Google-DKIM (optional) - penalty if failed - if results.XGoogleDkim != nil { - switch results.XGoogleDkim.Result { - case api.AuthResultResultPass: - // pass: don't alter the score - default: // fail - score -= 12 - } - } + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 // DMARC (25 points) - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 25 - case api.AuthResultResultNone: - score += 10 - default: // fail - score += 0 - } - } + score += 25 * a.calculateDMARCScore(results) / 100 // BIMI (10 points) - if results.Bimi != nil { - switch results.Bimi.Result { - case api.AuthResultResultPass: - score += 10 - case api.AuthResultResultDeclined: - score += 5 - default: // fail - score += 0 - } - } + score += 10 * a.calculateBIMIScore(results) / 100 // Ensure score doesn't exceed 100 if score > 100 { diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go new file mode 100644 index 0000000..01b7505 --- /dev/null +++ b/pkg/analyzer/authentication_arc.go @@ -0,0 +1,183 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.ARCResult{} + + // Extract result (pass, fail, none) + re := regexp.MustCompile(`arc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.ARCResultResult(resultStr) + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) + + return result +} + +// parseARCHeaders parses ARC headers from email message +// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &api.ARCResult{ + Result: api.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = api.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = api.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = api.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go new file mode 100644 index 0000000..9269d70 --- /dev/null +++ b/pkg/analyzer/authentication_arc_test.go @@ -0,0 +1,150 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.ARCResultResult + }{ + { + name: "ARC pass", + part: "arc=pass", + expectedResult: api.ARCResultResultPass, + }, + { + name: "ARC fail", + part: "arc=fail", + expectedResult: api.ARCResultResultFail, + }, + { + name: "ARC none", + part: "arc=none", + expectedResult: api.ARCResultResultNone, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + }) + } +} + +func TestValidateARCChain(t *testing.T) { + tests := []struct { + name string + arcAuthResults []string + arcMessageSig []string + arcSeal []string + expectedValid bool + }{ + { + name: "Empty chain is valid", + arcAuthResults: []string{}, + arcMessageSig: []string{}, + arcSeal: []string{}, + expectedValid: true, + }, + { + name: "Valid chain with single hop", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + }, + expectedValid: true, + }, + { + name: "Valid chain with two hops", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=2; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=2; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=2; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: true, + }, + { + name: "Invalid chain - missing one header type", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{}, + expectedValid: false, + }, + { + name: "Invalid chain - non-sequential instances", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=3; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=3; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=3; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: false, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) + + if valid != tt.expectedValid { + t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) + } + }) + } +} diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go new file mode 100644 index 0000000..0d68281 --- /dev/null +++ b/pkg/analyzer/authentication_bimi.go @@ -0,0 +1,75 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseBIMIResult parses BIMI result from Authentication-Results +// Example: bimi=pass header.d=example.com header.selector=default +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`bimi=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { + if results.Bimi != nil { + switch results.Bimi.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultDeclined: + return 59 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go new file mode 100644 index 0000000..b1b5468 --- /dev/null +++ b/pkg/analyzer/authentication_bimi_test.go @@ -0,0 +1,94 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "BIMI pass with domain and selector", + part: "bimi=pass header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI with short form (d= and selector=)", + part: "bimi=pass d=example.com selector=v1", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: api.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go new file mode 100644 index 0000000..9ce0dd2 --- /dev/null +++ b/pkg/analyzer/authentication_dkim.go @@ -0,0 +1,115 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDKIMResult parses DKIM result from Authentication-Results +// Example: dkim=pass header.d=example.com header.s=selector1 +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + + return result +} + +// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header +func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { + var results []api.AuthResult + + // Get all DKIM-Signature headers + dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] + for _, dkimHeader := range dkimHeaders { + result := api.AuthResult{ + Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone + } + + // Extract domain (d=) + domainRe := regexp.MustCompile(`d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (s=) + selectorRe := regexp.MustCompile(`s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + details := "DKIM signature present (verification status unknown)" + result.Details = &details + + results = append(results, result) + } + + return results +} + +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { + // Expect at least one passing signature + if results.Dkim != nil && len(*results.Dkim) > 0 { + hasPass := false + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + hasPass = true + break + } + } + if hasPass { + return 100 + } else { + // Has DKIM signatures but none passed + return 20 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go new file mode 100644 index 0000000..0d00031 --- /dev/null +++ b/pkg/analyzer/authentication_dkim_test.go @@ -0,0 +1,328 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "DKIM pass with domain and selector", + part: "dkim=pass header.d=example.com header.s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "DKIM fail", + part: "dkim=fail header.d=example.com header.s=selector1", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + expectedSelector: "selector1", + }, + { + name: "DKIM with short form (d= and s=)", + part: "dkim=pass d=example.com s=default", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + expectedSelector: "default", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + }) + } +} + +func TestParseLegacyDKIM(t *testing.T) { + tests := []struct { + name string + dkimSignatures []string + expectedCount int + expectedDomains []string + expectedSelector []string + }{ + { + name: "Single DKIM signature with domain and selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "Multiple DKIM signatures", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", + "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "example.com"}, + expectedSelector: []string{"selector1", "selector2"}, + }, + { + name: "DKIM signature with different domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", + }, + expectedCount: 1, + expectedDomains: []string{"mail.example.org"}, + expectedSelector: []string{"default"}, + }, + { + name: "DKIM signature with subdomain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", + }, + expectedCount: 1, + expectedDomains: []string{"newsletters.example.com"}, + expectedSelector: []string{"marketing"}, + }, + { + name: "Multiple signatures from different domains", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", + "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", + }, + expectedCount: 2, + expectedDomains: []string{"example.com", "relay.com"}, + expectedSelector: []string{"s1", "s2"}, + }, + { + name: "No DKIM signatures", + dkimSignatures: []string{}, + expectedCount: 0, + expectedDomains: []string{}, + expectedSelector: []string{}, + }, + { + name: "DKIM signature without selector", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{""}, + }, + { + name: "DKIM signature without domain", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; s=selector1; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{""}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with whitespace in parameters", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with multiline format", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"selector1"}, + }, + { + name: "DKIM signature with ed25519 algorithm", + dkimSignatures: []string{ + "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", + }, + expectedCount: 1, + expectedDomains: []string{"example.com"}, + expectedSelector: []string{"ed25519"}, + }, + { + name: "Complex real-world DKIM signature", + dkimSignatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", + }, + expectedCount: 1, + expectedDomains: []string{"google.com"}, + expectedSelector: []string{"20230601"}, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with DKIM-Signature headers + email := &EmailMessage{ + Header: make(map[string][]string), + } + if len(tt.dkimSignatures) > 0 { + email.Header["Dkim-Signature"] = tt.dkimSignatures + } + + results := analyzer.parseLegacyDKIM(email) + + // Check count + if len(results) != tt.expectedCount { + t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) + return + } + + // Check each result + for i, result := range results { + // All legacy DKIM results should have Result = none + if result.Result != api.AuthResultResultNone { + t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) + } + + // Check domain + if i < len(tt.expectedDomains) { + expectedDomain := tt.expectedDomains[i] + if expectedDomain != "" { + if result.Domain == nil { + t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) + } else if strings.TrimSpace(*result.Domain) != expectedDomain { + t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) + } + } + } + + // Check selector + if i < len(tt.expectedSelector) { + expectedSelector := tt.expectedSelector[i] + if expectedSelector != "" { + if result.Selector == nil { + t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) + } else if strings.TrimSpace(*result.Selector) != expectedSelector { + t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) + } + } + } + + // Check that Details is set + if result.Details == nil { + t.Errorf("Result[%d].Details = nil, expected non-nil", i) + } else { + expectedDetails := "DKIM signature present (verification status unknown)" + if *result.Details != expectedDetails { + t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) + } + } + } + }) + } +} + +func TestParseLegacyDKIM_Integration(t *testing.T) { + // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication + t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultNone { + t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { + t.Error("Expected domain to be 'example.com'") + } + }) + + t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { + analyzer := NewAuthenticationAnalyzer() + email := &EmailMessage{ + Header: make(map[string][]string), + } + // Both Authentication-Results and DKIM-Signature headers + email.Header["Authentication-Results"] = []string{ + "mx.example.com; dkim=pass header.d=verified.com header.s=s1", + } + email.Header["Dkim-Signature"] = []string{ + "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", + } + + results := analyzer.AnalyzeAuthentication(email) + + // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature + if results.Dkim == nil { + t.Fatal("Expected DKIM results, got nil") + } + if len(*results.Dkim) != 1 { + t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) + } + if (*results.Dkim)[0].Result != api.AuthResultResultPass { + t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) + } + if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { + t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") + } + }) +} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go new file mode 100644 index 0000000..329a5c9 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc.go @@ -0,0 +1,68 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseDMARCResult parses DMARC result from Authentication-Results +// Example: dmarc=pass action=none header.from=example.com +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dmarc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNone: + return 33 + default: // fail + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go new file mode 100644 index 0000000..d7fda84 --- /dev/null +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -0,0 +1,69 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go new file mode 100644 index 0000000..6538cbb --- /dev/null +++ b/pkg/analyzer/authentication_iprev.go @@ -0,0 +1,73 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseIPRevResult parses IP reverse lookup result from Authentication-Results +// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { + result := &api.IPRevResult{} + + // Extract result (pass, fail, temperror, permerror, none) + re := regexp.MustCompile(`iprev=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.IPRevResultResult(resultStr) + } + + // Extract IP address (smtp.remote-ip or remote-ip) + ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) + if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { + ip := matches[1] + result.Ip = &ip + } + + // Extract hostname from parentheses + hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) + if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { + hostname := matches[1] + result.Hostname = &hostname + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { + if results.Iprev != nil { + switch results.Iprev.Result { + case api.Pass: + return 100 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go new file mode 100644 index 0000000..d0529b5 --- /dev/null +++ b/pkg/analyzer/authentication_iprev_test.go @@ -0,0 +1,225 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseIPRevResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass with IP and hostname", + part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev pass without smtp prefix", + part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", + expectedResult: api.Fail, + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: api.PtrTo("unknown.host.com"), + }, + { + name: "IPRev temperror", + part: "iprev=temperror smtp.remote-ip=203.0.113.1", + expectedResult: api.Temperror, + expectedIP: api.PtrTo("203.0.113.1"), + expectedHostname: nil, + }, + { + name: "IPRev permerror", + part: "iprev=permerror smtp.remote-ip=192.0.2.100", + expectedResult: api.Permerror, + expectedIP: api.PtrTo("192.0.2.100"), + expectedHostname: nil, + }, + { + name: "IPRev with IPv6", + part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("2001:db8::1"), + expectedHostname: api.PtrTo("ipv6.example.com"), + }, + { + name: "IPRev with subdomain hostname", + part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.50"), + expectedHostname: api.PtrTo("mail.subdomain.example.com"), + }, + { + name: "IPRev pass without parentheses", + part: "iprev=pass smtp.remote-ip=192.0.2.200", + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.200"), + expectedHostname: nil, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseIPRevResult(tt.part) + + // Check result + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + // Check IP + if tt.expectedIP != nil { + if result.Ip == nil { + t.Errorf("IP = nil, want %v", *tt.expectedIP) + } else if *result.Ip != *tt.expectedIP { + t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) + } + } else { + if result.Ip != nil { + t.Errorf("IP = %v, want nil", *result.Ip) + } + } + + // Check hostname + if tt.expectedHostname != nil { + if result.Hostname == nil { + t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) + } else if *result.Hostname != *tt.expectedHostname { + t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) + } + } else { + if result.Hostname != nil { + t.Errorf("Hostname = %v, want nil", *result.Hostname) + } + } + + // Check details + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } + }) + } +} + +func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { + tests := []struct { + name string + header string + expectedIPRevResult *api.IPRevResultResult + expectedIP *string + expectedHostname *string + }{ + { + name: "IPRev pass in Authentication-Results", + header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), + }, + { + name: "IPRev with other authentication methods", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), + }, + { + name: "IPRev fail", + header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", + expectedIPRevResult: api.PtrTo(api.Fail), + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: nil, + }, + { + name: "No IPRev in header", + header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", + expectedIPRevResult: nil, + }, + { + name: "Multiple IPRev results - only first is parsed", + header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("first.com"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &api.AuthenticationResults{} + analyzer.parseAuthenticationResultsHeader(tt.header, results) + + // Check IPRev + if tt.expectedIPRevResult != nil { + if results.Iprev == nil { + t.Errorf("Expected IPRev result, got nil") + } else { + if results.Iprev.Result != *tt.expectedIPRevResult { + t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) + } + if tt.expectedIP != nil { + if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { + var gotIP string + if results.Iprev.Ip != nil { + gotIP = *results.Iprev.Ip + } + t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) + } + } + if tt.expectedHostname != nil { + if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { + var gotHostname string + if results.Iprev.Hostname != nil { + gotHostname = *results.Iprev.Hostname + } + t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) + } + } + } + } else { + if results.Iprev != nil { + t.Errorf("Expected no IPRev result, got %+v", results.Iprev) + } + } + }) + } +} diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go new file mode 100644 index 0000000..479c325 --- /dev/null +++ b/pkg/analyzer/authentication_spf.go @@ -0,0 +1,105 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseSPFResult parses SPF result from Authentication-Results +// Example: spf=pass smtp.mailfrom=sender@example.com +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`spf=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + result := &api.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = api.AuthResultResult(resultStr) + } + + result.Details = &receivedSPF + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + return 100 + case api.AuthResultResultNeutral, api.AuthResultResultNone: + return 50 + case api.AuthResultResultSoftfail: + return 17 + default: // fail, temperror, permerror + return 0 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go new file mode 100644 index 0000000..7a84c49 --- /dev/null +++ b/pkg/analyzer/authentication_spf_test.go @@ -0,0 +1,212 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseLegacySPF(t *testing.T) { + tests := []struct { + name string + receivedSPF string + expectedResult api.AuthResultResult + expectedDomain *string + expectNil bool + }{ + { + name: "SPF pass with envelope-from", + receivedSPF: `pass + (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) + receiver=mx.receiver.com; + identity=mailfrom; + envelope-from="user@example.com"; + helo=smtp.example.com; + client-ip=192.0.2.10`, + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("example.com"), + }, + { + name: "SPF fail with sender", + receivedSPF: `fail + (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) + receiver=mx.receiver.com; + identity=mailfrom; + sender="sender@test.com"; + helo=smtp.test.com; + client-ip=192.0.2.20`, + expectedResult: api.AuthResultResultFail, + expectedDomain: api.PtrTo("test.com"), + }, + { + name: "SPF softfail", + receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: api.PtrTo("example.org"), + }, + { + name: "SPF neutral", + receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: api.PtrTo("domain.net"), + }, + { + name: "SPF none", + receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", + expectedResult: api.AuthResultResultNone, + expectedDomain: api.PtrTo("company.io"), + }, + { + name: "SPF temperror", + receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", + expectedResult: api.AuthResultResultTemperror, + expectedDomain: api.PtrTo("shop.example"), + }, + { + name: "SPF permerror", + receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", + expectedResult: api.AuthResultResultPermerror, + expectedDomain: api.PtrTo("invalid.test"), + }, + { + name: "SPF pass without domain extraction", + receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", + expectedResult: api.AuthResultResultPass, + expectedDomain: nil, + }, + { + name: "Empty Received-SPF header", + receivedSPF: "", + expectNil: true, + }, + { + name: "SPF with unquoted envelope-from", + receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("mail.example.net"), + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock email message with Received-SPF header + email := &EmailMessage{ + Header: make(map[string][]string), + } + if tt.receivedSPF != "" { + email.Header["Received-Spf"] = []string{tt.receivedSPF} + } + + result := analyzer.parseLegacySPF(email) + + if tt.expectNil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result, got nil") + } + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + + if tt.expectedDomain != nil { + if result.Domain == nil { + t.Errorf("Domain = nil, want %v", *tt.expectedDomain) + } else if *result.Domain != *tt.expectedDomain { + t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) + } + } else { + if result.Domain != nil { + t.Errorf("Domain = %v, want nil", *result.Domain) + } + } + + if result.Details == nil { + t.Error("Expected Details to be set, got nil") + } else if *result.Details != tt.receivedSPF { + t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) + } + }) + } +} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index f0b7163..63f9e2d 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -22,230 +22,11 @@ package analyzer import ( - "strings" "testing" "git.happydns.org/happyDeliver/internal/api" ) -func TestParseSPFResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - }{ - { - name: "SPF pass with domain", - part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "SPF fail", - part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "SPF neutral", - part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: "example.com", - }, - { - name: "SPF softfail", - part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseSPFResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} - -func TestParseDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "DKIM pass with domain and selector", - part: "dkim=pass header.d=example.com header.s=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "DKIM fail", - part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - expectedSelector: "selector1", - }, - { - name: "DKIM with short form (d= and s=)", - part: "dkim=pass d=example.com s=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - }) - } -} - -func TestParseDMARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - }{ - { - name: "DMARC pass", - part: "dmarc=pass action=none header.from=example.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "DMARC fail", - part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} - -func TestParseBIMIResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "BIMI pass with domain and selector", - part: "bimi=pass header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "BIMI fail", - part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "BIMI with short form (d= and selector=)", - part: "bimi=pass d=example.com selector=v1", - expectedResult: api.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "v1", - }, - { - name: "BIMI none", - part: "bimi=none header.d=example.com", - expectedResult: api.AuthResultResultNone, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseBIMIResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if tt.expectedSelector != "" { - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - } - }) - } -} - func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string @@ -332,42 +113,6 @@ func TestGetAuthenticationScore(t *testing.T) { } } -func TestParseARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.ARCResultResult - }{ - { - name: "ARC pass", - part: "arc=pass", - expectedResult: api.ARCResultResultPass, - }, - { - name: "ARC fail", - part: "arc=fail", - expectedResult: api.ARCResultResultFail, - }, - { - name: "ARC none", - part: "arc=none", - expectedResult: api.ARCResultResultNone, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseARCResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - }) - } -} - func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string @@ -691,713 +436,3 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { } }) } - -func TestParseLegacySPF(t *testing.T) { - tests := []struct { - name string - receivedSPF string - expectedResult api.AuthResultResult - expectedDomain *string - expectNil bool - }{ - { - name: "SPF pass with envelope-from", - receivedSPF: `pass - (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) - receiver=mx.receiver.com; - identity=mailfrom; - envelope-from="user@example.com"; - helo=smtp.example.com; - client-ip=192.0.2.10`, - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("example.com"), - }, - { - name: "SPF fail with sender", - receivedSPF: `fail - (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) - receiver=mx.receiver.com; - identity=mailfrom; - sender="sender@test.com"; - helo=smtp.test.com; - client-ip=192.0.2.20`, - expectedResult: api.AuthResultResultFail, - expectedDomain: api.PtrTo("test.com"), - }, - { - name: "SPF softfail", - receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: api.PtrTo("example.org"), - }, - { - name: "SPF neutral", - receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: api.PtrTo("domain.net"), - }, - { - name: "SPF none", - receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", - expectedResult: api.AuthResultResultNone, - expectedDomain: api.PtrTo("company.io"), - }, - { - name: "SPF temperror", - receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", - expectedResult: api.AuthResultResultTemperror, - expectedDomain: api.PtrTo("shop.example"), - }, - { - name: "SPF permerror", - receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", - expectedResult: api.AuthResultResultPermerror, - expectedDomain: api.PtrTo("invalid.test"), - }, - { - name: "SPF pass without domain extraction", - receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: api.AuthResultResultPass, - expectedDomain: nil, - }, - { - name: "Empty Received-SPF header", - receivedSPF: "", - expectNil: true, - }, - { - name: "SPF with unquoted envelope-from", - receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("mail.example.net"), - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with Received-SPF header - email := &EmailMessage{ - Header: make(map[string][]string), - } - if tt.receivedSPF != "" { - email.Header["Received-Spf"] = []string{tt.receivedSPF} - } - - result := analyzer.parseLegacySPF(email) - - if tt.expectNil { - if result != nil { - t.Errorf("Expected nil result, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("Expected non-nil result, got nil") - } - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - if tt.expectedDomain != nil { - if result.Domain == nil { - t.Errorf("Domain = nil, want %v", *tt.expectedDomain) - } else if *result.Domain != *tt.expectedDomain { - t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) - } - } else { - if result.Domain != nil { - t.Errorf("Domain = %v, want nil", *result.Domain) - } - } - - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } else if *result.Details != tt.receivedSPF { - t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) - } - }) - } -} - -func TestValidateARCChain(t *testing.T) { - tests := []struct { - name string - arcAuthResults []string - arcMessageSig []string - arcSeal []string - expectedValid bool - }{ - { - name: "Empty chain is valid", - arcAuthResults: []string{}, - arcMessageSig: []string{}, - arcSeal: []string{}, - expectedValid: true, - }, - { - name: "Valid chain with single hop", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - }, - expectedValid: true, - }, - { - name: "Valid chain with two hops", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=2; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=2; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=2; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: true, - }, - { - name: "Invalid chain - missing one header type", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{}, - expectedValid: false, - }, - { - name: "Invalid chain - non-sequential instances", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=3; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=3; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=3; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: false, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) - - if valid != tt.expectedValid { - t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) - } - }) - } -} - -func TestParseLegacyDKIM(t *testing.T) { - tests := []struct { - name string - dkimSignatures []string - expectedCount int - expectedDomains []string - expectedSelector []string - }{ - { - name: "Single DKIM signature with domain and selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1; h=from:to:subject:date; bh=xyz; b=abc", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "Multiple DKIM signatures", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc123", - "v=1; a=rsa-sha256; d=example.com; s=selector2; b=def456", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "example.com"}, - expectedSelector: []string{"selector1", "selector2"}, - }, - { - name: "DKIM signature with different domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=mail.example.org; s=default; b=xyz789", - }, - expectedCount: 1, - expectedDomains: []string{"mail.example.org"}, - expectedSelector: []string{"default"}, - }, - { - name: "DKIM signature with subdomain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=newsletters.example.com; s=marketing; b=aaa", - }, - expectedCount: 1, - expectedDomains: []string{"newsletters.example.com"}, - expectedSelector: []string{"marketing"}, - }, - { - name: "Multiple signatures from different domains", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; s=s1; b=abc", - "v=1; a=rsa-sha256; d=relay.com; s=s2; b=def", - }, - expectedCount: 2, - expectedDomains: []string{"example.com", "relay.com"}, - expectedSelector: []string{"s1", "s2"}, - }, - { - name: "No DKIM signatures", - dkimSignatures: []string{}, - expectedCount: 0, - expectedDomains: []string{}, - expectedSelector: []string{}, - }, - { - name: "DKIM signature without selector", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{""}, - }, - { - name: "DKIM signature without domain", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; s=selector1; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{""}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with whitespace in parameters", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; d=example.com ; s=selector1 ; b=abc123", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with multiline format", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n\td=example.com; s=selector1;\r\n\th=from:to:subject:date;\r\n\tb=abc123def456ghi789", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"selector1"}, - }, - { - name: "DKIM signature with ed25519 algorithm", - dkimSignatures: []string{ - "v=1; a=ed25519-sha256; d=example.com; s=ed25519; b=xyz", - }, - expectedCount: 1, - expectedDomains: []string{"example.com"}, - expectedSelector: []string{"ed25519"}, - }, - { - name: "Complex real-world DKIM signature", - dkimSignatures: []string{ - "v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1234567890; x=1234567950; darn=example.com; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=abc123def456==; b=longsignaturehere==", - }, - expectedCount: 1, - expectedDomains: []string{"google.com"}, - expectedSelector: []string{"20230601"}, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with DKIM-Signature headers - email := &EmailMessage{ - Header: make(map[string][]string), - } - if len(tt.dkimSignatures) > 0 { - email.Header["Dkim-Signature"] = tt.dkimSignatures - } - - results := analyzer.parseLegacyDKIM(email) - - // Check count - if len(results) != tt.expectedCount { - t.Errorf("Expected %d results, got %d", tt.expectedCount, len(results)) - return - } - - // Check each result - for i, result := range results { - // All legacy DKIM results should have Result = none - if result.Result != api.AuthResultResultNone { - t.Errorf("Result[%d].Result = %v, want %v", i, result.Result, api.AuthResultResultNone) - } - - // Check domain - if i < len(tt.expectedDomains) { - expectedDomain := tt.expectedDomains[i] - if expectedDomain != "" { - if result.Domain == nil { - t.Errorf("Result[%d].Domain = nil, want %v", i, expectedDomain) - } else if strings.TrimSpace(*result.Domain) != expectedDomain { - t.Errorf("Result[%d].Domain = %v, want %v", i, *result.Domain, expectedDomain) - } - } - } - - // Check selector - if i < len(tt.expectedSelector) { - expectedSelector := tt.expectedSelector[i] - if expectedSelector != "" { - if result.Selector == nil { - t.Errorf("Result[%d].Selector = nil, want %v", i, expectedSelector) - } else if strings.TrimSpace(*result.Selector) != expectedSelector { - t.Errorf("Result[%d].Selector = %v, want %v", i, *result.Selector, expectedSelector) - } - } - } - - // Check that Details is set - if result.Details == nil { - t.Errorf("Result[%d].Details = nil, expected non-nil", i) - } else { - expectedDetails := "DKIM signature present (verification status unknown)" - if *result.Details != expectedDetails { - t.Errorf("Result[%d].Details = %v, want %v", i, *result.Details, expectedDetails) - } - } - } - }) - } -} - -func TestParseLegacyDKIM_Integration(t *testing.T) { - // Test that parseLegacyDKIM is properly integrated into AnalyzeAuthentication - t.Run("Legacy DKIM is used when no Authentication-Results", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultNone { - t.Errorf("Expected DKIM result to be 'none', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "example.com" { - t.Error("Expected domain to be 'example.com'") - } - }) - - t.Run("Legacy DKIM is NOT used when Authentication-Results present", func(t *testing.T) { - analyzer := NewAuthenticationAnalyzer() - email := &EmailMessage{ - Header: make(map[string][]string), - } - // Both Authentication-Results and DKIM-Signature headers - email.Header["Authentication-Results"] = []string{ - "mx.example.com; dkim=pass header.d=verified.com header.s=s1", - } - email.Header["Dkim-Signature"] = []string{ - "v=1; a=rsa-sha256; d=example.com; s=selector1; b=abc", - } - - results := analyzer.AnalyzeAuthentication(email) - - // Should use the Authentication-Results DKIM (pass from verified.com), not the legacy signature - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 1 { - t.Errorf("Expected 1 DKIM result, got %d", len(*results.Dkim)) - } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { - t.Errorf("Expected DKIM result to be 'pass', got %v", (*results.Dkim)[0].Result) - } - if (*results.Dkim)[0].Domain == nil || *(*results.Dkim)[0].Domain != "verified.com" { - t.Error("Expected domain to be 'verified.com' from Authentication-Results, not 'example.com' from legacy") - } - }) -} - -func TestParseIPRevResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.IPRevResultResult - expectedIP *string - expectedHostname *string - }{ - { - name: "IPRev pass with IP and hostname", - part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), - }, - { - name: "IPRev pass without smtp prefix", - part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), - }, - { - name: "IPRev fail", - part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", - expectedResult: api.Fail, - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: api.PtrTo("unknown.host.com"), - }, - { - name: "IPRev temperror", - part: "iprev=temperror smtp.remote-ip=203.0.113.1", - expectedResult: api.Temperror, - expectedIP: api.PtrTo("203.0.113.1"), - expectedHostname: nil, - }, - { - name: "IPRev permerror", - part: "iprev=permerror smtp.remote-ip=192.0.2.100", - expectedResult: api.Permerror, - expectedIP: api.PtrTo("192.0.2.100"), - expectedHostname: nil, - }, - { - name: "IPRev with IPv6", - part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("2001:db8::1"), - expectedHostname: api.PtrTo("ipv6.example.com"), - }, - { - name: "IPRev with subdomain hostname", - part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.50"), - expectedHostname: api.PtrTo("mail.subdomain.example.com"), - }, - { - name: "IPRev pass without parentheses", - part: "iprev=pass smtp.remote-ip=192.0.2.200", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.200"), - expectedHostname: nil, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseIPRevResult(tt.part) - - // Check result - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - // Check IP - if tt.expectedIP != nil { - if result.Ip == nil { - t.Errorf("IP = nil, want %v", *tt.expectedIP) - } else if *result.Ip != *tt.expectedIP { - t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) - } - } else { - if result.Ip != nil { - t.Errorf("IP = %v, want nil", *result.Ip) - } - } - - // Check hostname - if tt.expectedHostname != nil { - if result.Hostname == nil { - t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) - } else if *result.Hostname != *tt.expectedHostname { - t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) - } - } else { - if result.Hostname != nil { - t.Errorf("Hostname = %v, want nil", *result.Hostname) - } - } - - // Check details - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } - }) - } -} - -func TestParseXGoogleDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult api.AuthResultResult - expectedDomain string - expectedSelector string - }{ - { - name: "x-google-dkim pass with domain", - part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", - expectedResult: api.AuthResultResultPass, - expectedDomain: "1e100.net", - }, - { - name: "x-google-dkim pass with short form", - part: "x-google-dkim=pass d=gmail.com", - expectedResult: api.AuthResultResultPass, - expectedDomain: "gmail.com", - }, - { - name: "x-google-dkim fail", - part: "x-google-dkim=fail header.d=example.com", - expectedResult: api.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "x-google-dkim with minimal info", - part: "x-google-dkim=pass", - expectedResult: api.AuthResultResultPass, - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseXGoogleDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if tt.expectedDomain != "" { - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - } - }) - } -} - -func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { - tests := []struct { - name string - header string - expectedIPRevResult *api.IPRevResultResult - expectedIP *string - expectedHostname *string - }{ - { - name: "IPRev pass in Authentication-Results", - header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), - }, - { - name: "IPRev with other authentication methods", - header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), - }, - { - name: "IPRev fail", - header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", - expectedIPRevResult: api.PtrTo(api.Fail), - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: nil, - }, - { - name: "No IPRev in header", - header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", - expectedIPRevResult: nil, - }, - { - name: "Multiple IPRev results - only first is parsed", - header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("first.com"), - }, - } - - analyzer := NewAuthenticationAnalyzer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(tt.header, results) - - // Check IPRev - if tt.expectedIPRevResult != nil { - if results.Iprev == nil { - t.Errorf("Expected IPRev result, got nil") - } else { - if results.Iprev.Result != *tt.expectedIPRevResult { - t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) - } - if tt.expectedIP != nil { - if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { - var gotIP string - if results.Iprev.Ip != nil { - gotIP = *results.Iprev.Ip - } - t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) - } - } - if tt.expectedHostname != nil { - if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { - var gotHostname string - if results.Iprev.Hostname != nil { - gotHostname = *results.Iprev.Hostname - } - t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) - } - } - } - } else { - if results.Iprev != nil { - t.Errorf("Expected no IPRev result, got %+v", results.Iprev) - } - } - }) - } -} diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go new file mode 100644 index 0000000..4bba469 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -0,0 +1,73 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results +// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-google-dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + return result +} + +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case api.AuthResultResultPass: + // pass: don't alter the score + default: // fail + return -100 + } + } + + return 0 +} diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go new file mode 100644 index 0000000..be29a08 --- /dev/null +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -0,0 +1,83 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "x-google-dkim pass with domain", + part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", + expectedResult: api.AuthResultResultPass, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: api.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +}