diff --git a/api/schemas.yaml b/api/schemas.yaml index 0116246..53aa297 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -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] diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index b327a04..28548ea 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -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. 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.) - 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= 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 diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 1455028..46a3518 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -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, }, diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 9b4d900..e2b83f0 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -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 = { 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; + }); {#if dmarcRecord} @@ -68,9 +78,12 @@ No DMARC record exists for {fromDomain}. The record above was inherited from {#if isPsdFallback} - the Public Suffix Domain {dmarcRecord.domain} per RFC 9091. + the Public Suffix Domain {dmarcRecord.domain} via the DMARCbis + DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment). {:else} - the organizational domain {dmarcRecord.domain} per RFC 7489. + the organizational domain {dmarcRecord.domain} via the + DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain + fallback). {/if} @@ -123,6 +136,53 @@ {/if} + + {#if dmarcRecord.test_mode} +
+ Test Mode: + t=y (active) +
+ + Test mode active — DMARCbis-compliant receivers will + downgrade the effective policy one level: + {#if dmarcRecord.policy === "reject"} + p=reject is applied as p=quarantine. + {:else if dmarcRecord.policy === "quarantine"} + p=quarantine is applied as p=none (no action taken). + {:else} + p=none is unaffected by test mode. + {/if} + Aggregate reports are still generated normally. + This tag replaces the deprecated pct= for gradual rollout. +
+
+ {/if} + + + {#if dmarcRecord.psd === "y"} +
+ Public Suffix Domain: + psd=y +
+ + PSD declared — 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. +
+
+ {:else if dmarcRecord.psd === "n"} +
+ Organizational Domain Boundary: + psd=n +
+ + Org Domain declaredpsd=n explicitly declares + this as an Organizational Domain boundary. Subdomains with separate DNS + delegation will use their own independent DMARCbis Tree Walk. +
+
+ {/if} + {#if dmarcRecord.subdomain_policy} {@const mainStrength = policyStrength(dmarcRecord.policy)} @@ -202,7 +262,7 @@ {/if} - + {#if dmarcRecord.percentage !== undefined}
Enforcement Percentage: @@ -215,25 +275,35 @@ > {dmarcRecord.percentage}% +
+ + Deprecated tag — the pct= tag is removed in + DMARCbis. Many receivers already ignore it. For gradual rollout, replace it + with t=y (test mode); for full enforcement, simply remove + pct= from your record. + {#if dmarcRecord.percentage === 0} +
pct=0 is an anti-pattern — it was widely misused + as a signal to bypass DMARC entirely, which is one reason the tag was + removed. Use t=y instead. + {/if} +
{#if dmarcRecord.percentage === 100}
Full enforcement — all messages are subject to DMARC policy. - This provides maximum protection.
- {:else if dmarcRecord.percentage >= 50} + {:else if dmarcRecord.percentage > 0 && 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. + messages are subject to DMARC policy. Receivers ignoring pct= will apply + the full policy regardless.
- {:else} + {:else if dmarcRecord.percentage > 0}
Low enforcement — only {dmarcRecord.percentage}% of - messages are protected. Gradually increase to pct=100 for full - protection. + messages are protected. Receivers ignoring pct= will apply full policy.
{/if}
@@ -319,6 +389,30 @@ {/if} + + {#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri} +
+ + Deprecated tags detected — your record contains + {#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri} + rf= and ri= tags that are + {:else if dmarcRecord.deprecated_rf} + the rf= tag that is + {:else} + the ri= 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 + ri=. + {/if} + You can safely remove + {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"} + from your DMARC record. +
+ {/if} + {#if dmarcRecord.error}