From 3161e392e849af4fd68c7e2a21a2bdf610a896c5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 16:03:35 +0800 Subject: [PATCH] dmarc: add support for np= non-existent subdomain policy tag Implements parsing, scoring, CLI output, and UI display for the DMARC np= tag (DMARCbis draft-ietf-dmarc-dmarcbis), which controls policy for NXDOMAIN subdomains independently of sp=. The score deducts 15 points from the base and awards them back when np= is absent (good default) or its strength is equal to or stricter than the effective sp=/p= policy. --- api/schemas.yaml | 5 + internal/app/cli_analyzer.go | 3 + pkg/analyzer/dns_dmarc.go | 92 ++++++++++++------- pkg/analyzer/dns_dmarc_test.go | 54 +++++++++++ .../lib/components/DmarcRecordDisplay.svelte | 36 ++++++++ 5 files changed, 159 insertions(+), 31 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index fa908c4..025ddc8 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -905,6 +905,11 @@ components: enum: [none, quarantine, reject, unknown] description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy example: "quarantine" + 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 + example: "reject" percentage: type: integer minimum: 0 diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index d8336a5..c704c56 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -202,6 +202,9 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze if dns.DmarcRecord.SubdomainPolicy != nil { fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) } + if dns.DmarcRecord.NonexistentSubdomainPolicy != nil { + fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy) + } fmt.Fprintln(writer) if dns.DmarcRecord.Record != nil { fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 913b98a..b327a04 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -60,33 +60,36 @@ func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { policy := d.extractDMARCPolicy(rawRecord) subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) + nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord) percentage := d.extractDMARCPercentage(rawRecord) spfAlignment := d.extractDMARCSPFAlignment(rawRecord) dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) if !d.validateDMARC(rawRecord) { return &model.DMARCRecord{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: false, - Error: utils.PtrTo("DMARC record appears malformed"), + 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{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: true, + Domain: &foundDomain, + Record: &rawRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, } } @@ -98,8 +101,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { raw, notFound, err := d.lookupDMARCAt(domain) if err != nil { return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } if !notFound { @@ -112,8 +115,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { 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)), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } if !notFound { @@ -134,8 +137,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo("No DMARC record found"), + Valid: false, + Error: utils.PtrTo("No DMARC record found"), } } @@ -196,6 +199,18 @@ func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRec return nil } +// extractDMARCNonexistentSubdomainPolicy extracts non-existent subdomain policy from a DMARC record. +// Returns the np tag value or nil if not specified (defaults to effective sp/p policy). +// The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis). +func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *model.DMARCRecordNonexistentSubdomainPolicy { + re := regexp.MustCompile(`np=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(matches[1])) + } + 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 { @@ -244,26 +259,25 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) case "quarantine": // Good policy - no deduction case "none": - // Weakest policy - deduct 5 points + // Weakest policy - deduct 25 points score -= 25 } } - // Bonus points for strict alignment modes (2 points each) + // 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) + // Subdomain policy scoring (sp tag) - // +3 for stricter or equal subdomain policy, -3 for weaker + // +15 for stricter or equal subdomain policy, -15 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] @@ -278,6 +292,22 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) // 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 { diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index ed1c25d..1455028 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -439,6 +439,60 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { } } +func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Non-existent subdomain policy - none", + record: "v=DMARC1; p=quarantine; np=none", + expectedPolicy: utils.PtrTo("none"), + }, + { + name: "Non-existent subdomain policy - quarantine", + record: "v=DMARC1; p=reject; np=quarantine", + expectedPolicy: utils.PtrTo("quarantine"), + }, + { + name: "Non-existent subdomain policy - reject", + record: "v=DMARC1; p=quarantine; np=reject", + expectedPolicy: utils.PtrTo("reject"), + }, + { + name: "No np tag (defaults to effective sp/p policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with np and sp tags", + record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: utils.PtrTo("reject"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCNonexistentSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCNonexistentSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + func TestExtractDMARCPercentage(t *testing.T) { tests := []struct { name string diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 6f3b4a3..9b4d900 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -166,6 +166,42 @@ {/if} + + {#if dmarcRecord.nonexistent_subdomain_policy} + {@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)} + {@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)} +
+ Non-Existent Subdomain Policy: + + {dmarcRecord.nonexistent_subdomain_policy} + + {#if npStrength >= effectiveSubStrength} +
+ + Good configuration — non-existent subdomain policy is equal to or stricter + than the effective subdomain policy. +
+ {:else} +
+ + Weaker protection for non-existent subdomains — consider setting + np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy} to match your subdomain policy. +
+ {/if} +
+ + The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis), + a draft RFC updating RFC 7489. Support may vary across mail receivers. +
+
+ {/if} + {#if dmarcRecord.percentage !== undefined}