dmarc: implement DMARCbis DNS Tree Walk and new tag support
Replace RFC 7489 PSL-based org-domain lookup and RFC 9091 PSD DMARC fallback with the DMARCbis DNS Tree Walk algorithm (max 8 queries, 8-label shortcut, TLD records require psd=y). Add parsing for the new t= (test mode), psd= (y/n/u), and deprecated tag detection (pct, rf, ri). Update validateDMARC to accept p=-absent records with rua= per DMARCbis §4.7. Score t=y by downgrading effective policy one level. Surface user-facing advisories in DmarcRecordDisplay: deprecation warnings for pct=/rf=/ri=, test mode explanation with per-policy impact, and PSD/org-domain boundary notices.
This commit is contained in:
parent
1b8627ef86
commit
809bca02e4
4 changed files with 482 additions and 159 deletions
|
|
@ -926,14 +926,35 @@ components:
|
|||
nonexistent_subdomain_policy:
|
||||
type: string
|
||||
enum: [none, quarantine, reject, unknown]
|
||||
description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent
|
||||
description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis)
|
||||
example: "reject"
|
||||
percentage:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: Percentage of messages subjected to filtering (pct tag, default 100)
|
||||
description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead."
|
||||
example: 100
|
||||
test_mode:
|
||||
type: boolean
|
||||
description: "DMARCbis t= tag: when true (t=y), receivers downgrade effective policy one level (reject→quarantine, quarantine→none). Replaces the deprecated pct= tag for testing."
|
||||
example: false
|
||||
psd:
|
||||
type: string
|
||||
enum: [y, n, u]
|
||||
description: "DMARCbis psd= tag: y=this is a Public Suffix Domain, n=this is an Organizational Domain boundary, u=unknown (default, use DNS Tree Walk to determine)"
|
||||
example: "u"
|
||||
deprecated_pct:
|
||||
type: boolean
|
||||
description: "Whether the deprecated pct= tag was found in the record (pct is removed in DMARCbis; migrate to t=y for testing mode)"
|
||||
example: false
|
||||
deprecated_rf:
|
||||
type: boolean
|
||||
description: "Whether the deprecated rf= tag was found in the record (rf is removed in DMARCbis; failure report formats are now defined separately)"
|
||||
example: false
|
||||
deprecated_ri:
|
||||
type: boolean
|
||||
description: "Whether the deprecated ri= tag was found in the record (ri is removed in DMARCbis; aggregate reporting interval is now fixed at ≥24 hours)"
|
||||
example: false
|
||||
spf_alignment:
|
||||
type: string
|
||||
enum: [relaxed, strict]
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/model"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
)
|
||||
|
|
@ -62,84 +60,102 @@ func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMA
|
|||
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
||||
nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord)
|
||||
percentage := d.extractDMARCPercentage(rawRecord)
|
||||
testMode := d.extractDMARCTestMode(rawRecord)
|
||||
psd := d.extractDMARCPSD(rawRecord)
|
||||
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
||||
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
||||
deprecatedPct := percentage != nil
|
||||
deprecatedRf := d.hasDMARCTag(rawRecord, "rf")
|
||||
deprecatedRi := d.hasDMARCTag(rawRecord, "ri")
|
||||
|
||||
if !d.validateDMARC(rawRecord) {
|
||||
return &model.DMARCRecord{
|
||||
Domain: &foundDomain,
|
||||
Record: &rawRecord,
|
||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||
SubdomainPolicy: subdomainPolicy,
|
||||
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
||||
Percentage: percentage,
|
||||
SpfAlignment: spfAlignment,
|
||||
DkimAlignment: dkimAlignment,
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("DMARC record appears malformed"),
|
||||
}
|
||||
}
|
||||
|
||||
return &model.DMARCRecord{
|
||||
rec := &model.DMARCRecord{
|
||||
Domain: &foundDomain,
|
||||
Record: &rawRecord,
|
||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||
SubdomainPolicy: subdomainPolicy,
|
||||
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
||||
Percentage: percentage,
|
||||
TestMode: testMode,
|
||||
Psd: psd,
|
||||
SpfAlignment: spfAlignment,
|
||||
DkimAlignment: dkimAlignment,
|
||||
Valid: true,
|
||||
}
|
||||
if deprecatedPct {
|
||||
rec.DeprecatedPct = utils.PtrTo(true)
|
||||
}
|
||||
if deprecatedRf {
|
||||
rec.DeprecatedRf = utils.PtrTo(true)
|
||||
}
|
||||
if deprecatedRi {
|
||||
rec.DeprecatedRi = utils.PtrTo(true)
|
||||
}
|
||||
|
||||
if !d.validateDMARC(rawRecord) {
|
||||
rec.Valid = false
|
||||
rec.Error = utils.PtrTo("DMARC record appears malformed")
|
||||
return rec
|
||||
}
|
||||
|
||||
rec.Valid = true
|
||||
return rec
|
||||
}
|
||||
|
||||
// checkDMARCRecord looks up and validates the DMARC record for a domain.
|
||||
// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and
|
||||
// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y).
|
||||
// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10).
|
||||
// It queries _dmarc.<domain> and walks up the label hierarchy until a valid DMARC
|
||||
// record is found or all labels are exhausted. Maximum 8 DNS queries per message.
|
||||
// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label
|
||||
// suffix before resuming normally (to stay within the 8-query budget).
|
||||
// Single-label (TLD) records are only accepted when they carry psd=y.
|
||||
func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) {
|
||||
labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".")
|
||||
n := len(labels)
|
||||
|
||||
for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 {
|
||||
current := strings.Join(labels[i:], ".")
|
||||
|
||||
raw, notFound, lookupErr := d.lookupDMARCAt(current)
|
||||
if lookupErr != nil {
|
||||
return "", "", lookupErr
|
||||
}
|
||||
if !notFound {
|
||||
// Single-label (TLD) records are only used when the record explicitly opts in.
|
||||
if !strings.Contains(current, ".") {
|
||||
if d.extractDMARCPSDValue(raw) != "y" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return raw, current, nil
|
||||
}
|
||||
|
||||
// DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the
|
||||
// 7-label suffix for the next query rather than stepping one label at a time.
|
||||
if i == 0 && n >= 8 {
|
||||
i = n - 8 // the outer i++ will land at n-7 (7 labels from the right)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// checkDMARCRecord looks up and validates the DMARC record for a domain using
|
||||
// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the
|
||||
// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC
|
||||
// experimental fallback.
|
||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||
// Step 1: try exact domain (_dmarc.<domain>)
|
||||
raw, notFound, err := d.lookupDMARCAt(domain)
|
||||
raw, foundDomain, err := d.walkDNSForDMARC(domain)
|
||||
if err != nil {
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||
}
|
||||
}
|
||||
if !notFound {
|
||||
return d.parseDMARCRecord(domain, raw)
|
||||
}
|
||||
|
||||
// Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1)
|
||||
orgDomain := getOrganizationalDomain(domain)
|
||||
if orgDomain != domain {
|
||||
raw, notFound, err = d.lookupDMARCAt(orgDomain)
|
||||
if err != nil {
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||
}
|
||||
}
|
||||
if !notFound {
|
||||
return d.parseDMARCRecord(orgDomain, raw)
|
||||
if foundDomain == "" {
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("No DMARC record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y
|
||||
psd, _ := publicsuffix.PublicSuffix(domain)
|
||||
if psd != "" && psd != orgDomain {
|
||||
raw, notFound, err = d.lookupDMARCAt(psd)
|
||||
if err == nil && !notFound {
|
||||
// Only apply PSD DMARC when the record explicitly opts in with psd=y
|
||||
if strings.Contains(raw, "psd=y") {
|
||||
return d.parseDMARCRecord(psd, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("No DMARC record found"),
|
||||
}
|
||||
return d.parseDMARCRecord(foundDomain, raw)
|
||||
}
|
||||
|
||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||
|
|
@ -211,114 +227,156 @@ func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *mod
|
|||
return nil
|
||||
}
|
||||
|
||||
// extractDMARCPercentage extracts the percentage from a DMARC record
|
||||
// Returns the pct tag value or nil if not specified (defaults to 100)
|
||||
// extractDMARCPercentage extracts the percentage from a DMARC record.
|
||||
// Returns the pct tag value or nil if not specified (defaults to 100).
|
||||
// Note: pct= is deprecated in DMARCbis; use t= (test_mode) instead.
|
||||
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
||||
// Look for pct=<number>
|
||||
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
|
||||
// extractDMARCTestMode extracts the DMARCbis t= tag (test mode).
|
||||
// Returns true for t=y, false for t=n, nil if absent (defaults to false / full enforcement).
|
||||
func (d *DNSAnalyzer) extractDMARCTestMode(record string) *bool {
|
||||
re := regexp.MustCompile(`(?:^|;)\s*t=(y|n)(?:;|$|\s)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
v := matches[1] == "y"
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDMARCPSD extracts the DMARCbis psd= tag value as a typed enum.
|
||||
// Returns nil if the tag is absent (defaults to "u" / unknown).
|
||||
func (d *DNSAnalyzer) extractDMARCPSD(record string) *model.DMARCRecordPsd {
|
||||
v := d.extractDMARCPSDValue(record)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return utils.PtrTo(model.DMARCRecordPsd(v))
|
||||
}
|
||||
|
||||
// extractDMARCPSDValue returns the raw string value of psd= ("y", "n", "u") or "".
|
||||
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
|
||||
re := regexp.MustCompile(`(?:^|;)\s*psd=(y|n|u)(?:;|$|\s)`)
|
||||
matches := re.FindStringSubmatch(record)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasDMARCTag reports whether the given tag name appears in the record.
|
||||
func (d *DNSAnalyzer) hasDMARCTag(record, tag string) bool {
|
||||
re := regexp.MustCompile(`(?:^|;)\s*` + regexp.QuoteMeta(tag) + `=`)
|
||||
return re.MatchString(record)
|
||||
}
|
||||
|
||||
// validateDMARC performs basic DMARC record validation.
|
||||
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
|
||||
// rua= but no p= is treated as p=none and considered valid.
|
||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||
// Must start with v=DMARC1
|
||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a policy tag
|
||||
// p= absent is allowed in DMARCbis when rua= is present (treated as p=none).
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
return strings.Contains(record, "rua=")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
||||
// DMARC ties SPF and DKIM together and provides policy
|
||||
if results.DmarcRecord != nil {
|
||||
if results.DmarcRecord.Valid {
|
||||
score += 50
|
||||
// Bonus points for stricter policies
|
||||
if results.DmarcRecord.Policy != nil {
|
||||
switch *results.DmarcRecord.Policy {
|
||||
case "reject":
|
||||
// Strictest policy - full points already awarded
|
||||
score += 25
|
||||
case "quarantine":
|
||||
// Good policy - no deduction
|
||||
case "none":
|
||||
// Weakest policy - deduct 25 points
|
||||
score -= 25
|
||||
}
|
||||
}
|
||||
// Bonus points for strict alignment modes (5 points each)
|
||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
// Policy strength: none < quarantine < reject
|
||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||
mainPolicy := string(*results.DmarcRecord.Policy)
|
||||
if results.DmarcRecord == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Subdomain policy scoring (sp tag)
|
||||
// +15 for stricter or equal subdomain policy, -15 for weaker
|
||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||
mainStrength := policyStrength[mainPolicy]
|
||||
subStrength := policyStrength[subPolicy]
|
||||
|
||||
if subStrength >= mainStrength {
|
||||
// Subdomain policy is equal or stricter
|
||||
score += 15
|
||||
} else {
|
||||
// Subdomain policy is weaker
|
||||
score -= 15
|
||||
}
|
||||
} else {
|
||||
// No sp tag means subdomains inherit main policy (good default)
|
||||
score += 15
|
||||
}
|
||||
// Non-existent subdomain policy scoring (np tag, DMARCbis)
|
||||
// -15 from base; +15 back if absent (good default) or >= effective sp/p strength
|
||||
score -= 15
|
||||
effectiveSubPolicy := mainPolicy
|
||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
|
||||
}
|
||||
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
|
||||
score += 15
|
||||
} else {
|
||||
npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)]
|
||||
effectiveStrength := policyStrength[effectiveSubPolicy]
|
||||
if npStrength >= effectiveStrength {
|
||||
score += 15
|
||||
}
|
||||
}
|
||||
// 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
|
||||
if !results.DmarcRecord.Valid {
|
||||
if results.DmarcRecord.Record != nil {
|
||||
// Partial credit if a DMARC record exists but has issues
|
||||
score += 20
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
score += 50
|
||||
|
||||
// Determine effective policy: DMARCbis t=y downgrades policy one level.
|
||||
effectivePolicy := "none"
|
||||
if results.DmarcRecord.Policy != nil {
|
||||
effectivePolicy = string(*results.DmarcRecord.Policy)
|
||||
}
|
||||
testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode
|
||||
if testMode {
|
||||
switch effectivePolicy {
|
||||
case "reject":
|
||||
effectivePolicy = "quarantine"
|
||||
case "quarantine":
|
||||
effectivePolicy = "none"
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus/penalty for policy strength
|
||||
switch effectivePolicy {
|
||||
case "reject":
|
||||
score += 25
|
||||
case "none":
|
||||
score -= 25
|
||||
}
|
||||
|
||||
// Bonus points for strict alignment modes
|
||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
||||
score += 5
|
||||
}
|
||||
|
||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||
|
||||
// Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker
|
||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||
if policyStrength[subPolicy] >= policyStrength[effectivePolicy] {
|
||||
score += 15
|
||||
} else {
|
||||
score -= 15
|
||||
}
|
||||
} else {
|
||||
score += 15 // inherits main policy — good default
|
||||
}
|
||||
|
||||
// Non-existent subdomain policy scoring (np tag, DMARCbis)
|
||||
score -= 15
|
||||
effectiveSubPolicy := effectivePolicy
|
||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
|
||||
}
|
||||
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
|
||||
score += 15 // inherits subdomain/main policy — good default
|
||||
} else {
|
||||
npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)]
|
||||
if npStrength >= policyStrength[effectiveSubPolicy] {
|
||||
score += 15
|
||||
}
|
||||
}
|
||||
|
||||
// pct= scaling (deprecated in DMARCbis, kept for backward compatibility).
|
||||
// pct=0 is an anti-pattern: score it as zero enforcement.
|
||||
if results.DmarcRecord.Percentage != nil {
|
||||
pct := *results.DmarcRecord.Percentage
|
||||
score = score * pct / 100
|
||||
}
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantDomain: utils.PtrTo("mail.example.com"),
|
||||
},
|
||||
{
|
||||
name: "exact domain NXDOMAIN — falls back to org domain",
|
||||
name: "exact domain NXDOMAIN — tree walk reaches org domain",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.example.com": {orgRecord},
|
||||
|
|
@ -99,7 +99,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantDomain: utils.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "exact domain has no v=DMARC1 TXT — falls back to org domain",
|
||||
name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.mail.example.com": {"some-other-txt"},
|
||||
|
|
@ -109,7 +109,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantDomain: utils.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "both exact and org NXDOMAIN but PSD has psd=y — RFC 9091 fallback",
|
||||
name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.com": {psdRecord},
|
||||
|
|
@ -118,7 +118,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantDomain: utils.PtrTo("com"),
|
||||
},
|
||||
{
|
||||
name: "PSD record exists but no psd=y — no record returned",
|
||||
name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.com": {"v=DMARC1; p=none"},
|
||||
|
|
@ -127,14 +127,14 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantErrSubst: "No DMARC record found",
|
||||
},
|
||||
{
|
||||
name: "no record at any level",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{},
|
||||
name: "no record at any level",
|
||||
domain: "mail.example.com",
|
||||
txt: map[string][]string{},
|
||||
wantValid: false,
|
||||
wantErrSubst: "No DMARC record found",
|
||||
},
|
||||
{
|
||||
name: "DNS error on exact domain — no fallback, error returned",
|
||||
name: "DNS error on exact domain — error returned",
|
||||
domain: "mail.example.com",
|
||||
errMap: map[string]error{
|
||||
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
|
||||
|
|
@ -143,7 +143,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantErrSubst: "SERVFAIL",
|
||||
},
|
||||
{
|
||||
name: "domain already at org level — no redundant fallback",
|
||||
name: "domain already at org level — found immediately",
|
||||
domain: "example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.example.com": {orgRecord},
|
||||
|
|
@ -151,6 +151,33 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
|||
wantValid: true,
|
||||
wantDomain: utils.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "deep subdomain — tree walk finds record two levels up",
|
||||
domain: "a.b.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.example.com": {orgRecord},
|
||||
},
|
||||
wantValid: true,
|
||||
wantDomain: utils.PtrTo("example.com"),
|
||||
},
|
||||
{
|
||||
name: "8-label domain — shortcut to 7-label suffix on miss",
|
||||
domain: "a.b.c.d.e.f.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.b.c.d.e.f.example.com": {orgRecord},
|
||||
},
|
||||
wantValid: true,
|
||||
wantDomain: utils.PtrTo("b.c.d.e.f.example.com"),
|
||||
},
|
||||
{
|
||||
name: "psd=n record stops tree walk at that level",
|
||||
domain: "mail.sub.example.com",
|
||||
txt: map[string][]string{
|
||||
"_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"},
|
||||
},
|
||||
wantValid: true,
|
||||
wantDomain: utils.PtrTo("sub.example.com"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -234,6 +261,124 @@ func TestExtractDMARCPolicy(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCTestMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
wantMode *bool
|
||||
}{
|
||||
{
|
||||
name: "t=y sets test mode",
|
||||
record: "v=DMARC1; p=reject; t=y",
|
||||
wantMode: utils.PtrTo(true),
|
||||
},
|
||||
{
|
||||
name: "t=n explicitly disables test mode",
|
||||
record: "v=DMARC1; p=reject; t=n",
|
||||
wantMode: utils.PtrTo(false),
|
||||
},
|
||||
{
|
||||
name: "absent t tag returns nil",
|
||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||
wantMode: nil,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCTestMode(tt.record)
|
||||
if tt.wantMode == nil {
|
||||
if result != nil {
|
||||
t.Errorf("extractDMARCTestMode(%q) = %v, want nil", tt.record, *result)
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCTestMode(%q) = nil, want %v", tt.record, *tt.wantMode)
|
||||
}
|
||||
if *result != *tt.wantMode {
|
||||
t.Errorf("extractDMARCTestMode(%q) = %v, want %v", tt.record, *result, *tt.wantMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCPSD(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
wantPSD *string
|
||||
}{
|
||||
{
|
||||
name: "psd=y marks Public Suffix Domain",
|
||||
record: "v=DMARC1; p=none; psd=y",
|
||||
wantPSD: utils.PtrTo("y"),
|
||||
},
|
||||
{
|
||||
name: "psd=n marks Org Domain boundary",
|
||||
record: "v=DMARC1; p=reject; psd=n",
|
||||
wantPSD: utils.PtrTo("n"),
|
||||
},
|
||||
{
|
||||
name: "psd=u is explicit unknown",
|
||||
record: "v=DMARC1; p=quarantine; psd=u",
|
||||
wantPSD: utils.PtrTo("u"),
|
||||
},
|
||||
{
|
||||
name: "absent psd tag returns nil",
|
||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||
wantPSD: nil,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCPSD(tt.record)
|
||||
if tt.wantPSD == nil {
|
||||
if result != nil {
|
||||
t.Errorf("extractDMARCPSD(%q) = %v, want nil", tt.record, *result)
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCPSD(%q) = nil, want %q", tt.record, *tt.wantPSD)
|
||||
}
|
||||
if string(*result) != *tt.wantPSD {
|
||||
t.Errorf("extractDMARCPSD(%q) = %q, want %q", tt.record, string(*result), *tt.wantPSD)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDMARCTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
tag string
|
||||
want bool
|
||||
}{
|
||||
{name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", tag: "rf", want: true},
|
||||
{name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", tag: "ri", want: true},
|
||||
{name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", tag: "rf", want: false},
|
||||
{name: "ri tag absent", record: "v=DMARC1; p=quarantine", tag: "ri", want: false},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.hasDMARCTag(tt.record, tt.tag)
|
||||
if result != tt.want {
|
||||
t.Errorf("hasDMARCTag(%q, %q) = %v, want %v", tt.record, tt.tag, result, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDMARC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -250,13 +395,18 @@ func TestValidateDMARC(t *testing.T) {
|
|||
record: "v=DMARC1; p=none",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)",
|
||||
record: "v=DMARC1; rua=mailto:dmarc@example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no version",
|
||||
record: "p=quarantine",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no policy",
|
||||
name: "Invalid DMARC - no policy and no rua",
|
||||
record: "v=DMARC1",
|
||||
expected: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
const isFallback = $derived(
|
||||
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
|
||||
);
|
||||
// A single-label domain (no dot) is a TLD/PSD level fallback
|
||||
const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes("."));
|
||||
|
||||
// Helper function to determine policy strength
|
||||
|
|
@ -18,6 +19,15 @@
|
|||
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
||||
return strength[policy || "none"] || 0;
|
||||
};
|
||||
|
||||
// Effective policy after applying DMARCbis t=y downgrade
|
||||
const effectivePolicy = $derived((): string => {
|
||||
const p = dmarcRecord?.policy ?? "none";
|
||||
if (!dmarcRecord?.test_mode) return p;
|
||||
if (p === "reject") return "quarantine";
|
||||
if (p === "quarantine") return "none";
|
||||
return p;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if dmarcRecord}
|
||||
|
|
@ -68,9 +78,12 @@
|
|||
No DMARC record exists for <code>{fromDomain}</code>. The record above was
|
||||
inherited from
|
||||
{#if isPsdFallback}
|
||||
the Public Suffix Domain <code>{dmarcRecord.domain}</code> per RFC 9091.
|
||||
the Public Suffix Domain <code>{dmarcRecord.domain}</code> via the DMARCbis
|
||||
DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment).
|
||||
{:else}
|
||||
the organizational domain <code>{dmarcRecord.domain}</code> per RFC 7489.
|
||||
the organizational domain <code>{dmarcRecord.domain}</code> via the
|
||||
DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain
|
||||
fallback).
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -123,6 +136,53 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Test Mode (DMARCbis t= tag) -->
|
||||
{#if dmarcRecord.test_mode}
|
||||
<div class="mb-3">
|
||||
<strong>Test Mode:</strong>
|
||||
<span class="badge bg-warning">t=y (active)</span>
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-flask me-1"></i>
|
||||
<strong>Test mode active</strong> — DMARCbis-compliant receivers will
|
||||
downgrade the effective policy one level:
|
||||
{#if dmarcRecord.policy === "reject"}
|
||||
<code>p=reject</code> is applied as <code>p=quarantine</code>.
|
||||
{:else if dmarcRecord.policy === "quarantine"}
|
||||
<code>p=quarantine</code> is applied as <code>p=none</code> (no action taken).
|
||||
{:else}
|
||||
<code>p=none</code> is unaffected by test mode.
|
||||
{/if}
|
||||
Aggregate reports are still generated normally.
|
||||
This tag replaces the deprecated <code>pct=</code> for gradual rollout.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PSD tag (DMARCbis psd=) -->
|
||||
{#if dmarcRecord.psd === "y"}
|
||||
<div class="mb-3">
|
||||
<strong>Public Suffix Domain:</strong>
|
||||
<span class="badge bg-info">psd=y</span>
|
||||
<div class="alert alert-info mt-2 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>PSD declared</strong> — this domain is declared as a Public Suffix
|
||||
Domain. DMARCbis-compliant receivers will apply this policy to subdomains
|
||||
that have no DMARC record of their own when using the DNS Tree Walk algorithm.
|
||||
</div>
|
||||
</div>
|
||||
{:else if dmarcRecord.psd === "n"}
|
||||
<div class="mb-3">
|
||||
<strong>Organizational Domain Boundary:</strong>
|
||||
<span class="badge bg-info">psd=n</span>
|
||||
<div class="alert alert-info mt-2 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Org Domain declared</strong> — <code>psd=n</code> explicitly declares
|
||||
this as an Organizational Domain boundary. Subdomains with separate DNS
|
||||
delegation will use their own independent DMARCbis Tree Walk.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subdomain Policy -->
|
||||
{#if dmarcRecord.subdomain_policy}
|
||||
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
||||
|
|
@ -202,7 +262,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Percentage -->
|
||||
<!-- Percentage (pct=, deprecated in DMARCbis) -->
|
||||
{#if dmarcRecord.percentage !== undefined}
|
||||
<div class="mb-3">
|
||||
<strong>Enforcement Percentage:</strong>
|
||||
|
|
@ -215,25 +275,35 @@
|
|||
>
|
||||
{dmarcRecord.percentage}%
|
||||
</span>
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Deprecated tag</strong> — the <code>pct=</code> tag is removed in
|
||||
DMARCbis. Many receivers already ignore it. For gradual rollout, replace it
|
||||
with <code>t=y</code> (test mode); for full enforcement, simply remove
|
||||
<code>pct=</code> from your record.
|
||||
{#if dmarcRecord.percentage === 0}
|
||||
<br /><strong>pct=0 is an anti-pattern</strong> — it was widely misused
|
||||
as a signal to bypass DMARC entirely, which is one reason the tag was
|
||||
removed. Use <code>t=y</code> instead.
|
||||
{/if}
|
||||
</div>
|
||||
{#if dmarcRecord.percentage === 100}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
||||
This provides maximum protection.
|
||||
</div>
|
||||
{:else if dmarcRecord.percentage >= 50}
|
||||
{:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are subject to DMARC policy. Consider increasing to
|
||||
<code>pct=100</code> once you've validated your configuration.
|
||||
messages are subject to DMARC policy. Receivers ignoring pct= will apply
|
||||
the full policy regardless.
|
||||
</div>
|
||||
{:else}
|
||||
{:else if dmarcRecord.percentage > 0}
|
||||
<div class="alert alert-danger mt-2 mb-0 small">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are protected. Gradually increase to <code>pct=100</code> for full
|
||||
protection.
|
||||
messages are protected. Receivers ignoring pct= will apply full policy.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -319,6 +389,30 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Deprecated rf=/ri= tags -->
|
||||
{#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri}
|
||||
<div class="alert alert-warning mt-2 mb-3 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Deprecated tags detected</strong> — your record contains
|
||||
{#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri}
|
||||
<code>rf=</code> and <code>ri=</code> tags that are
|
||||
{:else if dmarcRecord.deprecated_rf}
|
||||
the <code>rf=</code> tag that is
|
||||
{:else}
|
||||
the <code>ri=</code> tag that is
|
||||
{/if}
|
||||
removed in DMARCbis. Modern receivers will ignore
|
||||
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}.
|
||||
{#if dmarcRecord.deprecated_ri}
|
||||
Aggregate reporting interval is now fixed at ≥ 24 hours regardless of
|
||||
<code>ri=</code>.
|
||||
{/if}
|
||||
You can safely remove
|
||||
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"}
|
||||
from your DMARC record.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if dmarcRecord.error}
|
||||
<div class="text-danger">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue