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.
This commit is contained in:
nemunaire 2026-05-18 16:03:35 +08:00
commit 3161e392e8
5 changed files with 159 additions and 31 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -166,6 +166,42 @@
</div>
{/if}
<!-- Non-Existent Subdomain Policy (np tag, DMARCbis) -->
{#if dmarcRecord.nonexistent_subdomain_policy}
{@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)}
{@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)}
<div class="mb-3">
<strong>Non-Existent Subdomain Policy:</strong>
<span
class="badge {dmarcRecord.nonexistent_subdomain_policy === 'reject'
? 'bg-success'
: dmarcRecord.nonexistent_subdomain_policy === 'quarantine'
? 'bg-warning'
: 'bg-secondary'}"
>
{dmarcRecord.nonexistent_subdomain_policy}
</span>
{#if npStrength >= effectiveSubStrength}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Good configuration</strong> — non-existent subdomain policy is equal to or stricter
than the effective subdomain policy.
</div>
{:else}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Weaker protection for non-existent subdomains</strong> — consider setting
<code>np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy}</code> to match your subdomain policy.
</div>
{/if}
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
The <code>np=</code> tag is introduced by <strong>DMARCbis</strong> (draft-ietf-dmarc-dmarcbis),
a draft RFC updating RFC 7489. Support may vary across mail receivers.
</div>
</div>
{/if}
<!-- Percentage -->
{#if dmarcRecord.percentage !== undefined}
<div class="mb-3">