Split authentication.go in one file per check

This commit is contained in:
nemunaire 2025-10-24 14:04:44 +07:00
commit a700db0873
16 changed files with 1860 additions and 1425 deletions

View file

@ -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 {