From e5c678174c027c9aa6281c1823a3b4e0b40339ba Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Oct 2025 12:31:20 +0700 Subject: [PATCH] Comprehensive DMARC record checks --- api/openapi.yaml | 21 ++ pkg/analyzer/dns.go | 136 +++++++++- pkg/analyzer/dns_test.go | 238 ++++++++++++++++++ .../lib/components/DmarcRecordDisplay.svelte | 211 ++++++++++++++++ web/src/lib/components/DnsRecordsCard.svelte | 49 +--- 5 files changed, 600 insertions(+), 55 deletions(-) create mode 100644 web/src/lib/components/DmarcRecordDisplay.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index c0acfab..23cf1b6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -914,6 +914,27 @@ components: enum: [none, quarantine, reject, unknown] description: DMARC policy example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" valid: type: boolean description: Whether the DMARC record is valid diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 9dc12fa..11a6e17 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -420,20 +420,38 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { // Extract policy policy := d.extractDMARCPolicy(dmarcRecord) + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + // Basic validation if !d.validateDMARC(dmarcRecord) { return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - Valid: false, - Error: api.PtrTo("DMARC record appears malformed"), + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: api.PtrTo("DMARC record appears malformed"), } } return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - Valid: true, + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, } } @@ -448,6 +466,71 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { return "unknown" } +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { + // Look for aspf=s (strict) or aspf=r (relaxed) + re := regexp.MustCompile(`aspf=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { + // Look for adkim=s (strict) or adkim=r (relaxed) + re := regexp.MustCompile(`adkim=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) +} + +// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record +// Returns the sp tag value or nil if not specified (defaults to main policy) +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { + // Look for sp=none, sp=quarantine, or sp=reject + re := regexp.MustCompile(`sp=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + // validateDMARC performs basic DMARC record validation func (d *DNSAnalyzer) validateDMARC(record string) bool { // Must start with v=DMARC1 @@ -657,7 +740,7 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // DMARC ties SPF and DKIM together and provides policy if results.DmarcRecord != nil { if results.DmarcRecord.Valid { - score += 15 + score += 10 // Bonus points for stricter policies if results.DmarcRecord.Policy != nil { switch *results.DmarcRecord.Policy { @@ -671,6 +754,43 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { score -= 5 } } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + score += 1 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + score += 1 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 3 + } else { + // Subdomain policy is weaker + score -= 3 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 3 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } } else if results.DmarcRecord.Record != nil { // Partial credit if DMARC record exists but has issues score += 5 diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 664ae5e..c397726 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -397,3 +397,241 @@ func TestValidateBIMI(t *testing.T) { }) } } + +func TestExtractDMARCSPFAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", + }, + { + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", + }, + { + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCDKIMAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", + }, + { + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: stringPtr("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: stringPtr("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: stringPtr("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: stringPtr("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + +func TestExtractDMARCPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: intPtr(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: intPtr(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: intPtr(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: intPtr(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: intPtr(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPercentage(tt.record) + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} + +// Helper functions for test pointers +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte new file mode 100644 index 0000000..30cddeb --- /dev/null +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -0,0 +1,211 @@ + + +{#if dmarcRecord} +
+
+
+ + Domain-based Message Authentication +
+ DMARC +
+
+

DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

+ + +
+ Status: + {#if dmarcRecord.valid} + Valid + {:else} + Invalid + {/if} +
+ + + {#if dmarcRecord.policy} +
+ Policy: + + {dmarcRecord.policy} + + {#if dmarcRecord.policy === 'reject'} +
+ + Maximum protection — emails failing DMARC checks are rejected. This provides the strongest defense against spoofing and phishing. +
+ {:else if dmarcRecord.policy === 'quarantine'} +
+ + Good protection — emails failing DMARC checks are quarantined (sent to spam). This is a safe middle ground.
+ + Once you've validated your configuration and ensured all legitimate mail passes, consider upgrading to p=reject for maximum protection. +
+ {:else if dmarcRecord.policy === 'none'} +
+ + Monitoring only — emails failing DMARC are delivered normally. This is only recommended during initial setup.
+ + After monitoring reports, upgrade to p=quarantine or p=reject to actively protect your domain. +
+ {:else} +
+ + Unknown policy — the policy value is not recognized. Valid options are: none, quarantine, or reject. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.subdomain_policy} + {@const mainStrength = policyStrength(dmarcRecord.policy)} + {@const subStrength = policyStrength(dmarcRecord.subdomain_policy)} +
+ Subdomain Policy: + + {dmarcRecord.subdomain_policy} + + {#if subStrength >= mainStrength} +
+ + Good configuration — subdomain policy is equal to or stricter than main policy. +
+ {:else} +
+ + Weaker subdomain protection — consider setting sp={dmarcRecord.policy} to match your main policy for consistent protection. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Subdomain Policy: + Inherits main policy +
+ + Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection. +
+
+ {/if} + + + {#if dmarcRecord.percentage !== undefined} +
+ Enforcement Percentage: + + {dmarcRecord.percentage}% + + {#if dmarcRecord.percentage === 100} +
+ + Full enforcement — all messages are subject to DMARC policy. This provides maximum protection. +
+ {:else if dmarcRecord.percentage >= 50} +
+ + Partial enforcement — only {dmarcRecord.percentage}% of messages are subject to DMARC policy. Consider increasing to pct=100 once you've validated your configuration. +
+ {:else} +
+ + Low enforcement — only {dmarcRecord.percentage}% of messages are protected. Gradually increase to pct=100 for full protection. +
+ {/if} +
+ {:else if dmarcRecord.policy} +
+ Enforcement Percentage: + 100% (default) +
+ + Full enforcement — all messages are subject to DMARC policy by default. +
+
+ {/if} + + + {#if dmarcRecord.spf_alignment} +
+ SPF Alignment: + + {dmarcRecord.spf_alignment} + + {#if dmarcRecord.spf_alignment === 'relaxed'} +
+ + Recommended for most senders — ensures legitimate subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are accepted. Ensure all legitimate mail comes from the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.dkim_alignment} +
+ DKIM Alignment: + + {dmarcRecord.dkim_alignment} + + {#if dmarcRecord.dkim_alignment === 'relaxed'} +
+ + Recommended for most senders — ensures legitimate subdomain mail passes.
+ + For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized. +
+ {:else} +
+ + Maximum brand protection — only exact domain matches are accepted. Ensure all DKIM signatures use the exact From domain. +
+ {/if} +
+ {/if} + + + {#if dmarcRecord.record} +
+ Record:
+ {dmarcRecord.record} +
+ {/if} + + + {#if dmarcRecord.error} +
+ Error: {dmarcRecord.error} +
+ {/if} +
+
+{/if} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 884d2c4..4984f61 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -3,6 +3,7 @@ import { getScoreColorClass } from "$lib/score"; import GradeDisplay from "./GradeDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; + import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte"; interface Props { dnsResults?: DNSResults; @@ -205,53 +206,7 @@ {/if} - {#if dnsResults.dmarc_record} -
-
-
- - Domain-based Message Authentication -
- DMARC -
-
-

DMARC builds on SPF and DKIM by telling receiving servers what to do with emails that fail authentication checks. It also enables reporting so you can monitor your email security.

-
- Status: - {#if dnsResults.dmarc_record.valid} - Valid - {:else} - Invalid - {/if} -
- {#if dnsResults.dmarc_record.policy} -
- Policy: - - {dnsResults.dmarc_record.policy} - -
- {/if} - {#if dnsResults.dmarc_record.record} -
- Record:
- {dnsResults.dmarc_record.record} -
- {/if} - {#if dnsResults.dmarc_record.error} -
- Error: {dnsResults.dmarc_record.error} -
- {/if} -
-
- {/if} + {#if dnsResults.bimi_record}