Compare commits

..

3 commits

Author SHA1 Message Date
c52a3aa8a7 Improve DMARC description
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-03 15:00:14 +07:00
5b179e7b93 Domain alignment checks for DKIM 2025-11-03 14:58:48 +07:00
465da6d16a Don't look at original DKIM keys headers 2025-11-03 14:58:23 +07:00
9 changed files with 412 additions and 369 deletions

View file

@ -664,6 +664,21 @@ components:
description: Reverse DNS (PTR record) for the IP address description: Reverse DNS (PTR record) for the IP address
example: "mail.example.com" example: "mail.example.com"
DKIMDomainInfo:
type: object
required:
- domain
- org_domain
properties:
domain:
type: string
description: DKIM signature domain
example: "mail.example.com"
org_domain:
type: string
description: Organizational domain extracted from DKIM domain (using Public Suffix List)
example: "example.com"
DomainAlignment: DomainAlignment:
type: object type: object
properties: properties:
@ -686,9 +701,8 @@ components:
dkim_domains: dkim_domains:
type: array type: array
items: items:
type: string $ref: '#/components/schemas/DKIMDomainInfo'
description: Domains from DKIM signatures description: Domains from DKIM signatures with their organizational domains
example: ["example.com"]
aligned: aligned:
type: boolean type: boolean
description: Whether all domains align (strict alignment - exact match) description: Whether all domains align (strict alignment - exact match)

View file

@ -50,13 +50,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
results.Spf = a.parseLegacySPF(email) results.Spf = a.parseLegacySPF(email)
} }
if results.Dkim == nil || len(*results.Dkim) == 0 {
dkimResults := a.parseLegacyDKIM(email)
if len(dkimResults) > 0 {
results.Dkim = &dkimResults
}
}
// Parse ARC headers if not already parsed from Authentication-Results // Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil { if results.Arc == nil {
results.Arc = a.parseARCHeaders(email) results.Arc = a.parseARCHeaders(email)

View file

@ -59,40 +59,6 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
return result 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) { func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
// Expect at least one passing signature // Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 { if results.Dkim != nil && len(*results.Dkim) > 0 {

View file

@ -22,7 +22,6 @@
package analyzer package analyzer
import ( import (
"strings"
"testing" "testing"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
@ -85,246 +84,3 @@ func TestParseDKIMResult(t *testing.T) {
}) })
} }
} }
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) {
hostname = ""
// 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")
}
})
}

View file

@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade := 6 maxGrade := 6
headers := *analysis.Headers headers := *analysis.Headers
// RP and From alignment (20 points) // RP and From alignment (25 points)
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
score += 20 // Bad domain alignment, cap grade to C
} else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned {
score += 15
} else {
maxGrade -= 2 maxGrade -= 2
} else if *analysis.DomainAlignment.Aligned {
score += 25
} else if *analysis.DomainAlignment.RelaxedAligned {
score += 20
} }
// Check required headers (RFC 5322) - 30 points // Check required headers (RFC 5322) - 30 points
@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade = 1 maxGrade = 1
} }
// Check recommended headers (20 points) // Check recommended headers (15 points)
recommendedHeaders := []string{"subject", "to"} recommendedHeaders := []string{"subject", "to"}
// Add reply-to when from is a no-reply address // Add reply-to when from is a no-reply address
@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
presentRecommended++ presentRecommended++
} }
} }
score += presentRecommended * 20 / recommendedCount score += presentRecommended * 15 / recommendedCount
if presentRecommended < recommendedCount { if presentRecommended < recommendedCount {
maxGrade -= 1 maxGrade -= 1
@ -235,7 +236,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
} }
// GenerateHeaderAnalysis creates structured header analysis from email // 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 { if email == nil {
return nil return nil
} }
@ -281,7 +282,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
} }
// Domain alignment // Domain alignment
domainAlignment := h.analyzeDomainAlignment(email) domainAlignment := h.analyzeDomainAlignment(email, authResults)
if domainAlignment != nil { if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment analysis.DomainAlignment = domainAlignment
} }
@ -352,8 +353,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
return check return check
} }
// analyzeDomainAlignment checks domain alignment between headers // analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
alignment := &api.DomainAlignment{ alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true), Aligned: api.PtrTo(true),
RelaxedAligned: 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) // Check alignment (strict and relaxed)
issues := []string{} 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 fromDomain := *alignment.FromDomain
rpDomain := *alignment.ReturnPathDomain rpDomain := *alignment.ReturnPathDomain
// Strict alignment: exact match (case-insensitive) // Strict alignment: exact match (case-insensitive)
strictAligned := strings.EqualFold(fromDomain, rpDomain) rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match // Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string var fromOrgDomain, rpOrgDomain string
@ -400,20 +432,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
if alignment.ReturnPathOrgDomain != nil { if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain rpOrgDomain = *alignment.ReturnPathOrgDomain
} }
relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
*alignment.Aligned = strictAligned if !rpStrictAligned {
*alignment.RelaxedAligned = relaxedAligned if rpRelaxedAligned {
if !strictAligned {
if relaxedAligned {
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)) 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 { } else {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain)) 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 { if len(issues) > 0 {
alignment.Issues = &issues alignment.Issues = &issues
} }

View file

@ -24,6 +24,7 @@ package analyzer
import ( import (
"net/mail" "net/mail"
"net/textproto" "net/textproto"
"strings"
"testing" "testing"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Generate header analysis first // Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email) analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
score, _ := analyzer.CalculateHeaderScore(analysis) score, _ := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore { if score < tt.minScore || score > tt.maxScore {
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, 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 { if alignment == nil {
t.Fatal("Expected non-nil alignment") 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", "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 { if analysis == nil {
t.Fatal("GenerateHeaderAnalysis returned nil") t.Fatal("GenerateHeaderAnalysis returned nil")
@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool {
} }
return *a == *b 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)
}
}
}
})
}
}

View file

@ -75,7 +75,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers // Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) 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.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email) results.RBL = r.rblChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)

View file

@ -34,9 +34,10 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="card-text small text-muted mb-2"> <p class="card-text small text-muted mb-2">
DMARC builds on SPF and DKIM by telling receiving servers what to do with emails DMARC enforces domain alignment requirements (regardless of the policy). It builds
that fail authentication checks. It also enables reporting so you can monitor your on SPF and DKIM by telling receiving servers what to do with emails that fail
email security. authentication checks. It also enables reporting so you can monitor your email
security.
</p> </p>
<hr /> <hr />

View file

@ -66,68 +66,148 @@
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="card-text small text-muted mb-3"> <p class="card-text small text-muted">
Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path or DKIM signature). Proper alignment is crucial for DMARC compliance, regardless of the policy. It helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. Only one of the following lines needs to pass.
</p> </p>
<div class="row"> </div>
<div class="col-md-3"> <div class="list-group list-group-flush">
<small class="text-muted">Strict Alignment</small> <div class="list-group-item d-flex ps-0">
<div> <div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}> SPF
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div> </div>
<div class="col-md-3"> <div class="row flex-grow-1">
<small class="text-muted">Relaxed Alignment</small> <div class="col-md-3">
<div> <small class="text-muted">Strict Alignment</small>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}> <div>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i> <span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong> <i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
</span> <strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</div> </span>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
</div> </div>
{/each} <div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div>
</div> </div>
{/if} {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
</div>
{/each}
</div>
{/if}
<!-- Alignment Information based on DMARC policy --> <!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}"> <div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.spf_alignment === 'strict'} {#if dmarcRecord.spf_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. <strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
{:else} {:else}
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass. <strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
{/if} {/if}
</div>
{/if}
</div>
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
{@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
{@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div class="list-group-item d-flex ps-0">
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
DKIM
</div>
<div class="flex-fill">
<div class="row flex-grow-1">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span class="badge" class:bg-success={dkim_aligned} class:bg-danger={!dkim_aligned}>
<i class="bi {dkim_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{dkim_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={dkim_relaxed_aligned} class:bg-danger={!dkim_relaxed_aligned}>
<i class="bi {dkim_relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{dkim_relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Signature Domain</small>
<div><code>{dkim_domain.domain || '-'}</code></div>
{#if dkim_domain.domain !== dkim_domain.org_domain}
<div class="small text-muted mt-1">Org: <code>{dkim_domain.org_domain}</code></div>
{/if}
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
</div>
{/each}
</div>
{/if}
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.dkim_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict DKIM alignment required</strong> — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed DKIM alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass.
{/if}
</div>
{/if}
{/if}
</div>
</div> </div>
{/if} {/each}
</div> </div>
</div> </div>
{/if} {/if}