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:
parent
1516991057
commit
3161e392e8
5 changed files with 159 additions and 31 deletions
|
|
@ -905,6 +905,11 @@ components:
|
||||||
enum: [none, quarantine, reject, unknown]
|
enum: [none, quarantine, reject, unknown]
|
||||||
description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy
|
description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy
|
||||||
example: "quarantine"
|
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:
|
percentage:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,9 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
|
||||||
if dns.DmarcRecord.SubdomainPolicy != nil {
|
if dns.DmarcRecord.SubdomainPolicy != nil {
|
||||||
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
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)
|
fmt.Fprintln(writer)
|
||||||
if dns.DmarcRecord.Record != nil {
|
if dns.DmarcRecord.Record != nil {
|
||||||
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
||||||
|
|
|
||||||
|
|
@ -60,33 +60,36 @@ func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool
|
||||||
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
|
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
|
||||||
policy := d.extractDMARCPolicy(rawRecord)
|
policy := d.extractDMARCPolicy(rawRecord)
|
||||||
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
||||||
|
nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord)
|
||||||
percentage := d.extractDMARCPercentage(rawRecord)
|
percentage := d.extractDMARCPercentage(rawRecord)
|
||||||
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
||||||
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
||||||
|
|
||||||
if !d.validateDMARC(rawRecord) {
|
if !d.validateDMARC(rawRecord) {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Domain: &foundDomain,
|
Domain: &foundDomain,
|
||||||
Record: &rawRecord,
|
Record: &rawRecord,
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
||||||
SpfAlignment: spfAlignment,
|
Percentage: percentage,
|
||||||
DkimAlignment: dkimAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
Valid: false,
|
DkimAlignment: dkimAlignment,
|
||||||
Error: utils.PtrTo("DMARC record appears malformed"),
|
Valid: false,
|
||||||
|
Error: utils.PtrTo("DMARC record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Domain: &foundDomain,
|
Domain: &foundDomain,
|
||||||
Record: &rawRecord,
|
Record: &rawRecord,
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
||||||
SpfAlignment: spfAlignment,
|
Percentage: percentage,
|
||||||
DkimAlignment: dkimAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
Valid: true,
|
DkimAlignment: dkimAlignment,
|
||||||
|
Valid: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,8 +101,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
raw, notFound, err := d.lookupDMARCAt(domain)
|
raw, notFound, err := d.lookupDMARCAt(domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !notFound {
|
if !notFound {
|
||||||
|
|
@ -112,8 +115,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
raw, notFound, err = d.lookupDMARCAt(orgDomain)
|
raw, notFound, err = d.lookupDMARCAt(orgDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !notFound {
|
if !notFound {
|
||||||
|
|
@ -134,8 +137,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DMARC record found"),
|
Error: utils.PtrTo("No DMARC record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +199,18 @@ func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRec
|
||||||
return nil
|
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
|
// extractDMARCPercentage extracts the percentage from a DMARC record
|
||||||
// Returns the pct tag value or nil if not specified (defaults to 100)
|
// Returns the pct tag value or nil if not specified (defaults to 100)
|
||||||
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
||||||
|
|
@ -244,26 +259,25 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int)
|
||||||
case "quarantine":
|
case "quarantine":
|
||||||
// Good policy - no deduction
|
// Good policy - no deduction
|
||||||
case "none":
|
case "none":
|
||||||
// Weakest policy - deduct 5 points
|
// Weakest policy - deduct 25 points
|
||||||
score -= 25
|
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 {
|
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
||||||
score += 5
|
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)
|
// 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 {
|
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||||
mainPolicy := string(*results.DmarcRecord.Policy)
|
|
||||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||||
|
|
||||||
// Policy strength: none < quarantine < reject
|
|
||||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
|
||||||
|
|
||||||
mainStrength := policyStrength[mainPolicy]
|
mainStrength := policyStrength[mainPolicy]
|
||||||
subStrength := policyStrength[subPolicy]
|
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)
|
// No sp tag means subdomains inherit main policy (good default)
|
||||||
score += 15
|
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)
|
// Percentage scoring (pct tag)
|
||||||
// Apply the percentage on the current score
|
// Apply the percentage on the current score
|
||||||
if results.DmarcRecord.Percentage != nil {
|
if results.DmarcRecord.Percentage != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,42 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Percentage -->
|
||||||
{#if dmarcRecord.percentage !== undefined}
|
{#if dmarcRecord.percentage !== undefined}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue