Domain alignment checks for DKIM
This commit is contained in:
parent
465da6d16a
commit
5b179e7b93
5 changed files with 408 additions and 81 deletions
|
|
@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
maxGrade := 6
|
||||
headers := *analysis.Headers
|
||||
|
||||
// RP and From alignment (20 points)
|
||||
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned {
|
||||
score += 20
|
||||
} else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned {
|
||||
score += 15
|
||||
} else {
|
||||
// RP and From alignment (25 points)
|
||||
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
|
||||
// Bad domain alignment, cap grade to C
|
||||
maxGrade -= 2
|
||||
} else if *analysis.DomainAlignment.Aligned {
|
||||
score += 25
|
||||
} else if *analysis.DomainAlignment.RelaxedAligned {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Check required headers (RFC 5322) - 30 points
|
||||
|
|
@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
maxGrade = 1
|
||||
}
|
||||
|
||||
// Check recommended headers (20 points)
|
||||
// Check recommended headers (15 points)
|
||||
recommendedHeaders := []string{"subject", "to"}
|
||||
|
||||
// Add reply-to when from is a no-reply address
|
||||
|
|
@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
presentRecommended++
|
||||
}
|
||||
}
|
||||
score += presentRecommended * 20 / recommendedCount
|
||||
score += presentRecommended * 15 / recommendedCount
|
||||
|
||||
if presentRecommended < recommendedCount {
|
||||
maxGrade -= 1
|
||||
|
|
@ -235,7 +236,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
|||
}
|
||||
|
||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis {
|
||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
|
||||
if email == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -281,7 +282,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
|
|||
}
|
||||
|
||||
// Domain alignment
|
||||
domainAlignment := h.analyzeDomainAlignment(email)
|
||||
domainAlignment := h.analyzeDomainAlignment(email, authResults)
|
||||
if domainAlignment != nil {
|
||||
analysis.DomainAlignment = domainAlignment
|
||||
}
|
||||
|
|
@ -352,8 +353,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
|||
return check
|
||||
}
|
||||
|
||||
// analyzeDomainAlignment checks domain alignment between headers
|
||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
|
||||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
|
||||
alignment := &api.DomainAlignment{
|
||||
Aligned: api.PtrTo(true),
|
||||
RelaxedAligned: api.PtrTo(true),
|
||||
|
|
@ -383,14 +384,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
|
|||
}
|
||||
}
|
||||
|
||||
// Extract DKIM domains from authentication results
|
||||
var dkimDomains []api.DKIMDomainInfo
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||
domain := *dkim.Domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
|
||||
Domain: domain,
|
||||
OrgDomain: orgDomain,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dkimDomains) > 0 {
|
||||
alignment.DkimDomains = &dkimDomains
|
||||
}
|
||||
|
||||
// Check alignment (strict and relaxed)
|
||||
issues := []string{}
|
||||
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
|
||||
|
||||
// hasReturnPath and hasDKIM track whether we have these fields to check
|
||||
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
|
||||
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
|
||||
|
||||
// If neither Return-Path nor DKIM is present, keep default alignment (true)
|
||||
// Otherwise, at least one must be aligned for overall alignment to be true
|
||||
strictAligned := !hasReturnPath && !hasDKIM
|
||||
relaxedAligned := !hasReturnPath && !hasDKIM
|
||||
|
||||
// Check Return-Path alignment
|
||||
rpStrictAligned := false
|
||||
rpRelaxedAligned := false
|
||||
if hasReturnPath {
|
||||
fromDomain := *alignment.FromDomain
|
||||
rpDomain := *alignment.ReturnPathDomain
|
||||
|
||||
// Strict alignment: exact match (case-insensitive)
|
||||
strictAligned := strings.EqualFold(fromDomain, rpDomain)
|
||||
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
|
||||
|
||||
// Relaxed alignment: organizational domain match
|
||||
var fromOrgDomain, rpOrgDomain string
|
||||
|
|
@ -400,20 +432,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
|
|||
if alignment.ReturnPathOrgDomain != nil {
|
||||
rpOrgDomain = *alignment.ReturnPathOrgDomain
|
||||
}
|
||||
relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain)
|
||||
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
|
||||
|
||||
*alignment.Aligned = strictAligned
|
||||
*alignment.RelaxedAligned = relaxedAligned
|
||||
|
||||
if !strictAligned {
|
||||
if relaxedAligned {
|
||||
if !rpStrictAligned {
|
||||
if rpRelaxedAligned {
|
||||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
|
||||
} else {
|
||||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
|
||||
}
|
||||
}
|
||||
|
||||
strictAligned = rpStrictAligned
|
||||
relaxedAligned = rpRelaxedAligned
|
||||
}
|
||||
|
||||
// Check DKIM alignment
|
||||
dkimStrictAligned := false
|
||||
dkimRelaxedAligned := false
|
||||
if hasDKIM {
|
||||
fromDomain := *alignment.FromDomain
|
||||
var fromOrgDomain string
|
||||
if alignment.FromOrgDomain != nil {
|
||||
fromOrgDomain = *alignment.FromOrgDomain
|
||||
}
|
||||
|
||||
for _, dkimDomain := range dkimDomains {
|
||||
// Check strict alignment for this DKIM signature
|
||||
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
|
||||
dkimStrictAligned = true
|
||||
}
|
||||
|
||||
// Check relaxed alignment for this DKIM signature
|
||||
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
|
||||
dkimRelaxedAligned = true
|
||||
}
|
||||
}
|
||||
|
||||
if !dkimStrictAligned && !dkimRelaxedAligned {
|
||||
// List all DKIM domains that failed alignment
|
||||
dkimDomainsList := []string{}
|
||||
for _, dkimDomain := range dkimDomains {
|
||||
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
|
||||
}
|
||||
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
|
||||
} else if !dkimStrictAligned && dkimRelaxedAligned {
|
||||
// DKIM has relaxed alignment but not strict
|
||||
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
|
||||
}
|
||||
|
||||
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
|
||||
// For DMARC compliance, at least one of SPF or DKIM must be aligned
|
||||
if dkimStrictAligned {
|
||||
strictAligned = true
|
||||
}
|
||||
if dkimRelaxedAligned {
|
||||
relaxedAligned = true
|
||||
}
|
||||
}
|
||||
|
||||
*alignment.Aligned = strictAligned
|
||||
*alignment.RelaxedAligned = relaxedAligned
|
||||
|
||||
if len(issues) > 0 {
|
||||
alignment.Issues = &issues
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ package analyzer
|
|||
import (
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
|
|
@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Generate header analysis first
|
||||
analysis := analyzer.GenerateHeaderAnalysis(tt.email)
|
||||
analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
|
||||
score, _ := analyzer.CalculateHeaderScore(analysis)
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
|
|
@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) {
|
|||
}),
|
||||
}
|
||||
|
||||
alignment := analyzer.analyzeDomainAlignment(email)
|
||||
alignment := analyzer.analyzeDomainAlignment(email, nil)
|
||||
|
||||
if alignment == nil {
|
||||
t.Fatal("Expected non-nil alignment")
|
||||
|
|
@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
|||
"from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||
}
|
||||
|
||||
analysis := analyzer.GenerateHeaderAnalysis(email)
|
||||
analysis := analyzer.GenerateHeaderAnalysis(email, nil)
|
||||
|
||||
if analysis == nil {
|
||||
t.Fatal("GenerateHeaderAnalysis returned nil")
|
||||
|
|
@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool {
|
|||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fromHeader string
|
||||
returnPath string
|
||||
dkimDomains []string
|
||||
expectStrictAligned bool
|
||||
expectRelaxedAligned bool
|
||||
expectIssuesContain string
|
||||
}{
|
||||
{
|
||||
name: "DKIM strict alignment with From domain",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "",
|
||||
dkimDomains: []string{"example.com"},
|
||||
expectStrictAligned: true,
|
||||
expectRelaxedAligned: true,
|
||||
expectIssuesContain: "",
|
||||
},
|
||||
{
|
||||
name: "DKIM relaxed alignment only",
|
||||
fromHeader: "sender@mail.example.com",
|
||||
returnPath: "",
|
||||
dkimDomains: []string{"example.com"},
|
||||
expectStrictAligned: false,
|
||||
expectRelaxedAligned: true,
|
||||
expectIssuesContain: "relaxed alignment",
|
||||
},
|
||||
{
|
||||
name: "DKIM no alignment",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "",
|
||||
dkimDomains: []string{"different.com"},
|
||||
expectStrictAligned: false,
|
||||
expectRelaxedAligned: false,
|
||||
expectIssuesContain: "do not align",
|
||||
},
|
||||
{
|
||||
name: "Multiple DKIM signatures - one aligns",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "",
|
||||
dkimDomains: []string{"different.com", "example.com"},
|
||||
expectStrictAligned: true,
|
||||
expectRelaxedAligned: true,
|
||||
expectIssuesContain: "",
|
||||
},
|
||||
{
|
||||
name: "Return-Path misaligned but DKIM aligned",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "bounce@different.com",
|
||||
dkimDomains: []string{"example.com"},
|
||||
expectStrictAligned: true,
|
||||
expectRelaxedAligned: true,
|
||||
expectIssuesContain: "Return-Path",
|
||||
},
|
||||
{
|
||||
name: "Return-Path aligned, no DKIM",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "bounce@example.com",
|
||||
dkimDomains: []string{},
|
||||
expectStrictAligned: true,
|
||||
expectRelaxedAligned: true,
|
||||
expectIssuesContain: "",
|
||||
},
|
||||
{
|
||||
name: "Both Return-Path and DKIM misaligned",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "bounce@other.com",
|
||||
dkimDomains: []string{"different.com"},
|
||||
expectStrictAligned: false,
|
||||
expectRelaxedAligned: false,
|
||||
expectIssuesContain: "do not",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": tt.fromHeader,
|
||||
"Return-Path": tt.returnPath,
|
||||
}),
|
||||
}
|
||||
|
||||
// Create authentication results with DKIM signatures
|
||||
var authResults *api.AuthenticationResults
|
||||
if len(tt.dkimDomains) > 0 {
|
||||
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
|
||||
for _, domain := range tt.dkimDomains {
|
||||
dkimResults = append(dkimResults, api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
Domain: &domain,
|
||||
})
|
||||
}
|
||||
authResults = &api.AuthenticationResults{
|
||||
Dkim: &dkimResults,
|
||||
}
|
||||
}
|
||||
|
||||
alignment := analyzer.analyzeDomainAlignment(email, authResults)
|
||||
|
||||
if alignment == nil {
|
||||
t.Fatal("Expected non-nil alignment")
|
||||
}
|
||||
|
||||
if alignment.Aligned == nil {
|
||||
t.Fatal("Expected non-nil Aligned field")
|
||||
}
|
||||
|
||||
if *alignment.Aligned != tt.expectStrictAligned {
|
||||
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned)
|
||||
}
|
||||
|
||||
if alignment.RelaxedAligned == nil {
|
||||
t.Fatal("Expected non-nil RelaxedAligned field")
|
||||
}
|
||||
|
||||
if *alignment.RelaxedAligned != tt.expectRelaxedAligned {
|
||||
t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned)
|
||||
}
|
||||
|
||||
// Check DKIM domains are populated
|
||||
if len(tt.dkimDomains) > 0 {
|
||||
if alignment.DkimDomains == nil {
|
||||
t.Error("Expected DkimDomains to be populated")
|
||||
} else if len(*alignment.DkimDomains) != len(tt.dkimDomains) {
|
||||
t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains))
|
||||
}
|
||||
}
|
||||
|
||||
// Check issues contain expected string
|
||||
if tt.expectIssuesContain != "" {
|
||||
if alignment.Issues == nil || len(*alignment.Issues) == 0 {
|
||||
t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain)
|
||||
} else {
|
||||
found := false
|
||||
for _, issue := range *alignment.Issues {
|
||||
if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
|
||||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email)
|
||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue