Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
b3b1a094de dmarc: refactor parseDMARCRecord to use shared tag parser and eliminate helper methods
All checks were successful
continuous-integration/drone/push Build is passing
Replace per-field regex extractor methods with a single parseDKIMTags call,
removing eight redundant private methods and unifying DMARC tag parsing with
the existing DKIM tag parser. Tests are updated to drive through parseDMARCRecord
instead of the removed helpers, and the NP scoring logic is corrected to award
+15/−15 symmetrically like the SP scoring path.
2026-05-18 21:02:53 +08:00
809bca02e4 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.
2026-05-18 20:57:31 +08:00
1b8627ef86 dkim: expose algorithm, hash list, and key size in DKIM record analysis
Parse k=, h=, a= tags and derive RSA key bit-length from the public key
so consumers can detect weak configurations (SHA-1, short keys).
Scoring now penalises rsa-sha1 (cap 60), RSA <1024 bit (cap 25), and
RSA <2048 bit (cap 75); Ed25519 receives no penalty.

Fixes: #37
2026-05-18 20:57:31 +08:00
369a13526f analyzer: correct auth scoring weights, x-aligned-from penalty, and RBL divide-by-zero 2026-05-18 20:57:31 +08:00
3161e392e8 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.
2026-05-18 17:03:58 +08:00
1516991057 dmarc: implement RFC 7489 org-domain fallback and RFC 9091 PSD DMARC
DMARC lookup now follows the full RFC 7489 §6.6.3 fallback chain: exact
From domain → organizational domain (eTLD+1 via PSL) → public suffix
domain (RFC 9091, only when psd=y is present). DNS errors abort
immediately without triggering fallback; NXDOMAIN and missing v=DMARC1
records do trigger it. The found domain is exposed in the new
DMARCRecord.domain field for reporting purposes.

Also promote getOrganizationalDomain to a package-level function so both
HeaderAnalyzer and DNSAnalyzer can share it, and fix pre-existing
rbl_test.go compilation errors and stale score expectations.

Closes: #98
2026-05-18 17:03:58 +08:00
0de67af847 chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-18 00:08:25 +00:00
e324e6cbf9 chore(deps): update module github.com/oapi-codegen/runtime to v1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-16 11:17:18 +08:00
19 changed files with 1825 additions and 975 deletions

View file

@ -873,6 +873,24 @@ components:
type: string type: string
description: DKIM record content description: DKIM record content
example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."
key_type:
type: string
description: "Key type from k= tag (e.g. rsa, ed25519); defaults to rsa if absent"
example: "rsa"
hash_algorithms:
type: array
items:
type: string
description: "Acceptable hash algorithms from h= tag; empty means all accepted (RFC 6376 default: sha256)"
example: ["sha256"]
signing_algorithm:
type: string
description: "Algorithm used in DKIM-Signature a= tag (e.g. rsa-sha256, ed25519-sha256)"
example: "rsa-sha256"
key_size:
type: integer
description: "Public key size in bits (RSA: 1024/2048/4096; Ed25519: always 256)"
example: 2048
valid: valid:
type: boolean type: boolean
description: Whether the DKIM record is valid description: Whether the DKIM record is valid
@ -891,6 +909,10 @@ components:
type: string type: string
description: DMARC record content description: DMARC record content
example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
domain:
type: string
description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used)
example: "example.com"
policy: policy:
type: string type: string
enum: [none, quarantine, reject, unknown] enum: [none, quarantine, reject, unknown]
@ -901,12 +923,38 @@ 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 (DMARCbis)
example: "reject"
percentage: percentage:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 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 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: spf_alignment:
type: string type: string
enum: [relaxed, strict] enum: [relaxed, strict]

2
go.mod
View file

@ -8,7 +8,7 @@ require (
github.com/getkin/kin-openapi v0.138.0 github.com/getkin/kin-openapi v0.138.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.3.0 github.com/oapi-codegen/runtime v1.4.0
golang.org/x/net v0.54.0 golang.org/x/net v0.54.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0

4
go.sum
View file

@ -135,8 +135,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=

View file

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

View file

@ -174,9 +174,7 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut
score += 12 * a.calculateXGoogleDKIMScore(results) / 100 score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// Penalty-only: X-Aligned-From (up to -5 points on failure) // Penalty-only: X-Aligned-From (up to -5 points on failure)
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { score += 5 * a.calculateXAlignedFromScore(results) / 100
score += 5 * (xAlignedScore - 100) / 100
}
// Ensure score doesn't exceed 100 // Ensure score doesn't exceed 100
if score > 100 { if score > 100 {

View file

@ -47,7 +47,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: model.AuthResultResultPass, Result: model.AuthResultResultPass,
}, },
}, },
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30
}, },
{ {
name: "SPF and DKIM only", name: "SPF and DKIM only",
@ -59,7 +59,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: model.AuthResultResultPass}, {Result: model.AuthResultResultPass},
}, },
}, },
expectedScore: 48, // SPF=25 + DKIM=23 expectedScore: 60, // SPF=30 + DKIM=30
}, },
{ {
name: "SPF fail, DKIM pass", name: "SPF fail, DKIM pass",
@ -71,7 +71,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: model.AuthResultResultPass}, {Result: model.AuthResultResultPass},
}, },
}, },
expectedScore: 23, // SPF=0 + DKIM=23 expectedScore: 30, // SPF=0 + DKIM=30
}, },
{ {
name: "SPF softfail", name: "SPF softfail",
@ -80,7 +80,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: model.AuthResultResultSoftfail, Result: model.AuthResultResultSoftfail,
}, },
}, },
expectedScore: 4, expectedScore: 5, // 30 * 17 / 100 = 5
}, },
{ {
name: "No authentication", name: "No authentication",
@ -97,7 +97,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: model.AuthResultResultPass, Result: model.AuthResultResultPass,
}, },
}, },
expectedScore: 35, // SPF (25) + BIMI (10) expectedScore: 40, // SPF (30) + BIMI (10)
}, },
} }

View file

@ -51,16 +51,16 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe
if results.XAlignedFrom != nil { if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result { switch results.XAlignedFrom.Result {
case model.AuthResultResultPass: case model.AuthResultResultPass:
// pass: positive contribution // pass: no impact
return 100 return 0
case model.AuthResultResultFail: case model.AuthResultResultFail:
// fail: negative contribution // fail: negative contribution
return 0 return -100
default: default:
// neutral, none, etc.: no impact // neutral, none, etc.: no impact
return 0 return 0
} }
} }
return 100 return 0
} }

View file

@ -92,18 +92,18 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
expectedScore int expectedScore int
}{ }{
{ {
name: "pass result gives positive score", name: "pass result gives no penalty",
result: &model.AuthResult{ result: &model.AuthResult{
Result: model.AuthResultResultPass, Result: model.AuthResultResultPass,
}, },
expectedScore: 100, expectedScore: 0,
}, },
{ {
name: "fail result gives zero score", name: "fail result gives full penalty",
result: &model.AuthResult{ result: &model.AuthResult{
Result: model.AuthResultResultFail, Result: model.AuthResultResultFail,
}, },
expectedScore: 0, expectedScore: -100,
}, },
{ {
name: "neutral result gives zero score", name: "neutral result gives zero score",

View file

@ -106,7 +106,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
// Check DKIM records by parsing DKIM-Signature headers directly // Check DKIM records by parsing DKIM-Signature headers directly
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) dkimRecord := d.checkDKIMRecord(sig)
if dkimRecord != nil { if dkimRecord != nil {
if results.DkimRecords == nil { if results.DkimRecords == nil {
results.DkimRecords = new([]model.DKIMRecord) results.DkimRecords = new([]model.DKIMRecord)

View file

@ -23,6 +23,8 @@ package analyzer
import ( import (
"context" "context"
"crypto/x509"
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
@ -30,17 +32,18 @@ import (
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
) )
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. // DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header.
type DKIMHeader struct { type DKIMHeader struct {
Domain string Domain string
Selector string Selector string
Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
} }
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. // parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
func parseDKIMSignatures(signatures []string) []DKIMHeader { func parseDKIMSignatures(signatures []string) []DKIMHeader {
var results []DKIMHeader var results []DKIMHeader
for _, sig := range signatures { for _, sig := range signatures {
var domain, selector string var domain, selector, algorithm string
for _, part := range strings.Split(sig, ";") { for _, part := range strings.Split(sig, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2) kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 { if len(kv) != 2 {
@ -53,19 +56,61 @@ func parseDKIMSignatures(signatures []string) []DKIMHeader {
domain = val domain = val
case "s": case "s":
selector = val selector = val
case "a":
algorithm = val
} }
} }
if domain != "" && selector != "" { if domain != "" && selector != "" {
results = append(results, DKIMHeader{Domain: domain, Selector: selector}) results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
} }
} }
return results return results
} }
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector // parseDKIMTags splits a DKIM DNS record into a tag→value map.
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord { func parseDKIMTags(record string) map[string]string {
// DKIM records are at: selector._domainkey.domain tags := make(map[string]string)
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) for _, part := range strings.Split(record, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return tags
}
// parseKeySize derives the public key bit length from a base64-encoded DER public key.
// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
func parseKeySize(keyType, p string) *int {
switch strings.ToLower(keyType) {
case "ed25519":
return utils.PtrTo(256)
case "rsa", "":
der, err := base64.StdEncoding.DecodeString(p)
if err != nil {
// Try without padding
der, err = base64.RawStdEncoding.DecodeString(p)
if err != nil {
return nil
}
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil
}
if rsaPub, ok := pub.(interface{ Size() int }); ok {
bits := rsaPub.Size() * 8
return &bits
}
return nil
}
return nil
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel() defer cancel()
@ -73,53 +118,83 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil { if err != nil {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
} }
} }
if len(txtRecords) == 0 { if len(txtRecords) == 0 {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo("No DKIM record found"), Valid: false,
Error: utils.PtrTo("No DKIM record found"),
} }
} }
// Concatenate all TXT record parts (DKIM can be split) // Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "") dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) { if !d.validateDKIM(dkimRecord) {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Record: utils.PtrTo(dkimRecord), Record: utils.PtrTo(dkimRecord),
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo("DKIM record appears malformed"), Valid: false,
Error: utils.PtrTo("DKIM record appears malformed"),
} }
} }
tags := parseDKIMTags(dkimRecord)
keyType := tags["k"]
if keyType == "" {
keyType = "rsa" // RFC 6376 default
}
var hashAlgorithms []string
if h, ok := tags["h"]; ok && h != "" {
for _, alg := range strings.Split(h, ":") {
if a := strings.TrimSpace(alg); a != "" {
hashAlgorithms = append(hashAlgorithms, a)
}
}
}
if hashAlgorithms == nil {
hashAlgorithms = []string{}
}
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Record: &dkimRecord, Record: &dkimRecord,
Valid: true, KeyType: utils.PtrTo(keyType),
HashAlgorithms: &hashAlgorithms,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
KeySize: parseKeySize(keyType, tags["p"]),
Valid: true,
} }
} }
// validateDKIM performs basic DKIM record validation func signingAlgorithmPtr(a string) *string {
if a == "" {
return nil
}
return &a
}
// validateDKIM performs basic DKIM record validation.
func (d *DNSAnalyzer) validateDKIM(record string) bool { func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") { if !strings.Contains(record, "p=") {
return false return false
} }
// Often contains v=DKIM1 but not required // If v= is present, it must be DKIM1
// If v= is present, it should be DKIM1
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false return false
} }
@ -128,21 +203,57 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
} }
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
// DKIM provides strong email authentication if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { return 0
hasValidDKIM := false }
for _, dkim := range *results.DkimRecords {
if dkim.Valid { hasValid := false
hasValidDKIM = true for _, dkim := range *results.DkimRecords {
break if dkim.Valid {
hasValid = true
break
}
}
if !hasValid {
return 25
}
score = 100
// Apply security penalties on the best valid record
for _, dkim := range *results.DkimRecords {
if !dkim.Valid {
continue
}
// SHA-1 signing is deprecated (RFC 8301)
if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
if score > 60 {
score = 60
} }
} }
if hasValidDKIM {
score += 100 // Key size penalties apply only to RSA
} else { keyType := ""
// Partial credit if DKIM record exists but has issues if dkim.KeyType != nil {
score += 25 keyType = strings.ToLower(*dkim.KeyType)
} }
if keyType == "rsa" || keyType == "" {
if dkim.KeySize != nil {
switch {
case *dkim.KeySize < 1024:
if score > 25 {
score = 25
}
case *dkim.KeySize < 2048:
if score > 75 {
score = 75
}
}
}
}
// Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
} }
return return

View file

@ -22,6 +22,10 @@
package analyzer package analyzer
import ( import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"testing" "testing"
"time" "time"
) )
@ -47,56 +51,56 @@ func TestParseDKIMSignatures(t *testing.T) {
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
}, },
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}}, expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Microsoft 365 style", name: "Microsoft 365 style",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
}, },
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}}, expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Tab-folded multiline (Postfix-style)", name: "Tab-folded multiline (Postfix-style)",
signatures: []string{ signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
}, },
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}}, expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Space-folded multiline (RFC-style)", name: "Space-folded multiline (RFC-style)",
signatures: []string{ signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
}, },
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "d= and s= on separate continuation lines", name: "d= and s= on separate continuation lines",
signatures: []string{ signatures: []string{
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
}, },
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}}, expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "No space after semicolons", name: "No space after semicolons",
signatures: []string{ signatures: []string{
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
}, },
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Multiple spaces after semicolons", name: "Multiple spaces after semicolons",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Ed25519 signature (RFC 8463)", name: "Ed25519 signature (RFC 8463)",
signatures: []string{ signatures: []string{
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
}, },
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}},
}, },
{ {
name: "Multiple signatures (ESP double-signing)", name: "Multiple signatures (ESP double-signing)",
@ -105,8 +109,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "mydomain.com", Selector: "mail"}, {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"},
{Domain: "sendib.com", Selector: "mail"}, {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -116,8 +120,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "football.example.com", Selector: "brisbane"}, {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"},
{Domain: "football.example.com", Selector: "test"}, {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -127,8 +131,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -136,56 +140,56 @@ func TestParseDKIMSignatures(t *testing.T) {
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}}, expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Deeply nested subdomain", name: "Deeply nested subdomain",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}}, expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Selector with hyphens (Microsoft 365 custom domain style)", name: "Selector with hyphens (Microsoft 365 custom domain style)",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Selector with dots", name: "Selector with dots",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Single-character selector", name: "Single-character selector",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}}, expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Postmark-style timestamp selector, s= before d=", name: "Postmark-style timestamp selector, s= before d=",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
}, },
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}}, expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}},
}, },
{ {
name: "d= and s= at the very end", name: "d= and s= at the very end",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
}, },
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Full tag set", name: "Full tag set",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
}, },
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Missing d= tag", name: "Missing d= tag",
@ -216,8 +220,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "good.com", Selector: "sel1"}, {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"},
{Domain: "also-good.com", Selector: "sel2"}, {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"},
}, },
}, },
} }
@ -235,6 +239,9 @@ func TestParseDKIMSignatures(t *testing.T) {
if result[i].Selector != tt.expected[i].Selector { if result[i].Selector != tt.expected[i].Selector {
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
} }
if result[i].Algorithm != tt.expected[i].Algorithm {
t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm)
}
} }
}) })
} }
@ -284,3 +291,119 @@ func TestValidateDKIM(t *testing.T) {
}) })
} }
} }
func TestParseDKIMTags(t *testing.T) {
tests := []struct {
name string
record string
wantTags map[string]string
}{
{
name: "standard RSA record",
record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256",
wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"},
},
{
name: "ed25519 record",
record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS",
wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"},
},
{
name: "missing k= defaults",
record: "v=DKIM1; p=MIIBI",
wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"},
},
{
name: "empty record",
record: "",
wantTags: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDKIMTags(tt.record)
for key, want := range tt.wantTags {
if got[key] != want {
t.Errorf("tag %q = %q, want %q", key, got[key], want)
}
}
})
}
}
func TestParseKeySize(t *testing.T) {
// Generate a real RSA key for testing
rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024)
rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048)
der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey)
der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey)
p1024 := base64.StdEncoding.EncodeToString(der1024)
p2048 := base64.StdEncoding.EncodeToString(der2048)
tests := []struct {
name string
keyType string
p string
want *int
}{
{
name: "RSA 1024",
keyType: "rsa",
p: p1024,
want: intPtr(1024),
},
{
name: "RSA 2048",
keyType: "rsa",
p: p2048,
want: intPtr(2048),
},
{
name: "Ed25519 always 256",
keyType: "ed25519",
p: "11qYAYKxCrfVS",
want: intPtr(256),
},
{
name: "Unknown key type",
keyType: "unknown",
p: "somedata",
want: nil,
},
{
name: "Invalid RSA base64",
keyType: "rsa",
p: "!!!not-base64!!!",
want: nil,
},
{
name: "Empty k= defaults to RSA",
keyType: "",
p: p2048,
want: intPtr(2048),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseKeySize(tt.keyType, tt.p)
if tt.want == nil {
if got != nil {
t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got)
}
return
}
if got == nil {
t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want)
}
if *got != *tt.want {
t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want)
}
})
}
}
func intPtr(v int) *int { return &v }

View file

@ -24,233 +24,290 @@ package analyzer
import ( import (
"context" "context"
"fmt" "fmt"
"regexp" "net"
"strconv"
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
) )
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2}
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
// lookupDMARCAt queries _dmarc.<domain> and returns the raw DMARC1 TXT record.
// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred.
func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel() defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain))
if lookupErr != nil {
if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound {
return "", true, nil
}
return "", false, lookupErr
}
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
return txt, false, nil
}
}
return "", true, nil
}
// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model.
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
tags := parseDKIMTags(rawRecord)
// Policy
policy := "unknown"
switch tags["p"] {
case "none", "quarantine", "reject":
policy = tags["p"]
}
// SPF alignment (default: relaxed)
spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
if tags["aspf"] == "s" {
spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
}
// DKIM alignment (default: relaxed)
dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
if tags["adkim"] == "s" {
dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
}
// Subdomain policy
var subdomainPolicy *model.DMARCRecordSubdomainPolicy
switch tags["sp"] {
case "none", "quarantine", "reject":
subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"]))
}
// Non-existent subdomain policy (DMARCbis np=)
var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy
switch tags["np"] {
case "none", "quarantine", "reject":
nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"]))
}
// Percentage (pct=, deprecated in DMARCbis)
var percentage *int
if pctStr, ok := tags["pct"]; ok {
if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 {
percentage = &pct
}
}
// Test mode (DMARCbis t=)
var testMode *bool
if t, ok := tags["t"]; ok {
v := t == "y"
testMode = &v
}
// PSD (DMARCbis psd=)
var psd *model.DMARCRecordPsd
switch tags["psd"] {
case "y", "n", "u":
psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"]))
}
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,
}
if percentage != nil {
rec.DeprecatedPct = utils.PtrTo(true)
}
if _, ok := tags["rf"]; ok {
rec.DeprecatedRf = utils.PtrTo(true)
}
if _, ok := tags["ri"]; ok {
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
}
// 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 {
raw, foundDomain, err := d.walkDNSForDMARC(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 foundDomain == "" {
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if 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"),
} }
} }
return d.parseDMARCRecord(foundDomain, raw)
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Extract subdomain policy
subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord)
// Extract percentage
percentage := d.extractDMARCPercentage(dmarcRecord)
// Extract alignment modes
spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord)
dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &model.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: false,
Error: utils.PtrTo("DMARC record appears malformed"),
}
}
return &model.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: true,
}
} }
// extractDMARCPolicy extracts the policy from a DMARC record // extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent.
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { // Used during DNS Tree Walk before full record parsing.
// Look for p=none, p=quarantine, or p=reject func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
re := regexp.MustCompile(`p=(none|quarantine|reject)`) v := parseDKIMTags(record)["psd"]
matches := re.FindStringSubmatch(record) switch v {
if len(matches) > 1 { case "y", "n", "u":
return matches[1] return v
} }
return "unknown" return ""
} }
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record // validateDMARC performs basic DMARC record validation.
// Returns "relaxed" (default) or "strict" // Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment { // rua= but no p= is treated as p=none and considered valid.
// Look for aspf=s (strict) or aspf=r (relaxed)
re := regexp.MustCompile(`aspf=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
}
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment {
// Look for adkim=s (strict) or adkim=r (relaxed)
re := regexp.MustCompile(`adkim=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
}
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
// Returns the sp tag value or nil if not specified (defaults to main policy)
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy {
// Look for sp=none, sp=quarantine, or sp=reject
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1]))
}
// If sp is not specified, it defaults to the main policy (p tag)
// Return nil to indicate it's using the default
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 {
// 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
func (d *DNSAnalyzer) validateDMARC(record string) bool { func (d *DNSAnalyzer) validateDMARC(record string) bool {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") { if !strings.HasPrefix(record, "v=DMARC1") {
return false 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=") { if !strings.Contains(record, "p=") {
return false return strings.Contains(record, "rua=")
} }
return true return true
} }
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { 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 != nil { return
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 5 points
score -= 25
}
}
// Bonus points for strict alignment modes (2 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
}
// Subdomain policy scoring (sp tag)
// +3 for stricter or equal subdomain policy, -3 for weaker
if results.DmarcRecord.SubdomainPolicy != nil {
mainPolicy := string(*results.DmarcRecord.Policy)
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
// Policy strength: none < quarantine < reject if !results.DmarcRecord.Valid {
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} if results.DmarcRecord.Record != nil {
// Partial credit if a DMARC record exists but has issues
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
}
// 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
score += 20 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
}
// Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker
if results.DmarcRecord.SubdomainPolicy != nil {
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] {
score += 15
} else {
score -= 15
}
} else {
score += 15 // inherits main policy — good default
}
// Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker
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 if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] {
score += 15
} else {
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 return

View file

@ -22,14 +22,206 @@
package analyzer package analyzer
import ( import (
"context"
"fmt"
"net"
"testing" "testing"
"time" "time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
) )
func TestExtractDMARCPolicy(t *testing.T) { // mockDNSResolver maps domain names to TXT records for testing.
// An entry with value nil means NXDOMAIN; an error value triggers a DNS error.
type mockDNSResolver struct {
txt map[string][]string
err map[string]error
}
func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
if err, ok := m.err[name]; ok {
return nil, err
}
if records, ok := m.txt[name]; ok {
return records, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) {
return nil, nil
}
func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer {
if errMap == nil {
errMap = map[string]error{}
}
return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap})
}
func TestCheckDMARCRecordFallback(t *testing.T) {
const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
const subRecord = "v=DMARC1; p=reject"
const psdRecord = "v=DMARC1; p=none; psd=y"
tests := []struct {
name string
domain string
txt map[string][]string
errMap map[string]error
wantValid bool
wantDomain *string
wantErrSubst string
}{
{
name: "exact domain has DMARC record — no fallback",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.mail.example.com": {subRecord},
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("mail.example.com"),
},
{
name: "exact domain NXDOMAIN — tree walk reaches org domain",
domain: "mail.example.com",
txt: map[string][]string{
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
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"},
"_dmarc.example.com": {orgRecord},
},
wantValid: true,
wantDomain: utils.PtrTo("example.com"),
},
{
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},
},
wantValid: true,
wantDomain: utils.PtrTo("com"),
},
{
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"},
},
wantValid: false,
wantErrSubst: "No DMARC record found",
},
{
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 — error returned",
domain: "mail.example.com",
errMap: map[string]error{
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
},
wantValid: false,
wantErrSubst: "SERVFAIL",
},
{
name: "domain already at org level — found immediately",
domain: "example.com",
txt: map[string][]string{
"_dmarc.example.com": {orgRecord},
},
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 {
t.Run(tt.name, func(t *testing.T) {
analyzer := newMockAnalyzer(tt.txt, tt.errMap)
result := analyzer.checkDMARCRecord(tt.domain)
if result.Valid != tt.wantValid {
t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid)
}
if tt.wantDomain != nil {
if result.Domain == nil {
t.Fatalf("Domain = nil, want %q", *tt.wantDomain)
}
if *result.Domain != *tt.wantDomain {
t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain)
}
}
if tt.wantErrSubst != "" {
if result.Error == nil {
t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst)
}
if !contains(*result.Error, tt.wantErrSubst) {
t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst)
}
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func TestParseDMARCRecordPolicy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
record string record string
@ -61,9 +253,135 @@ func TestExtractDMARCPolicy(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPolicy(tt.record) rec := analyzer.parseDMARCRecord("example.com", tt.record)
if result != tt.expectedPolicy { if rec.Policy == nil {
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record)
}
if string(*rec.Policy) != tt.expectedPolicy {
t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy)
}
})
}
}
func TestParseDMARCRecordTestMode(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.parseDMARCRecord("example.com", tt.record).TestMode
if tt.wantMode == nil {
if result != nil {
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode)
}
if *result != *tt.wantMode {
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode)
}
}
})
}
}
func TestParseDMARCRecordPSD(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.parseDMARCRecord("example.com", tt.record).Psd
if tt.wantPSD == nil {
if result != nil {
t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD)
}
if string(*result) != *tt.wantPSD {
t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD)
}
}
})
}
}
func TestParseDMARCRecordDeprecatedTags(t *testing.T) {
tests := []struct {
name string
record string
wantRf bool
wantRi bool
}{
{name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false},
{name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true},
{name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false},
{name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := analyzer.parseDMARCRecord("example.com", tt.record)
gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf
gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi
if gotRf != tt.wantRf {
t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf)
}
if gotRi != tt.wantRi {
t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi)
} }
}) })
} }
@ -85,13 +403,18 @@ func TestValidateDMARC(t *testing.T) {
record: "v=DMARC1; p=none", record: "v=DMARC1; p=none",
expected: true, 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", name: "Invalid DMARC - no version",
record: "p=quarantine", record: "p=quarantine",
expected: false, expected: false,
}, },
{ {
name: "Invalid DMARC - no policy", name: "Invalid DMARC - no policy and no rua",
record: "v=DMARC1", record: "v=DMARC1",
expected: false, expected: false,
}, },
@ -114,142 +437,36 @@ func TestValidateDMARC(t *testing.T) {
} }
} }
func TestExtractDMARCSPFAlignment(t *testing.T) { func TestParseDMARCRecordAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
}{
{
name: "SPF alignment - strict",
record: "v=DMARC1; p=quarantine; aspf=s",
expectedAlignment: "strict",
},
{
name: "SPF alignment - relaxed (explicit)",
record: "v=DMARC1; p=quarantine; aspf=r",
expectedAlignment: "relaxed",
},
{
name: "SPF alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=quarantine",
expectedAlignment: "relaxed",
},
{
name: "Both alignments specified - check SPF strict",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedAlignment: "strict",
},
{
name: "Both alignments specified - check SPF relaxed",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "relaxed",
},
{
name: "Complex record with SPF strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
expectedAlignment: "strict",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSPFAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
})
}
}
func TestExtractDMARCDKIMAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
}{
{
name: "DKIM alignment - strict",
record: "v=DMARC1; p=reject; adkim=s",
expectedAlignment: "strict",
},
{
name: "DKIM alignment - relaxed (explicit)",
record: "v=DMARC1; p=reject; adkim=r",
expectedAlignment: "relaxed",
},
{
name: "DKIM alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=none",
expectedAlignment: "relaxed",
},
{
name: "Both alignments specified - check DKIM strict",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "strict",
},
{
name: "Both alignments specified - check DKIM relaxed",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedAlignment: "relaxed",
},
{
name: "Complex record with DKIM strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100",
expectedAlignment: "strict",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCDKIMAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
})
}
}
func TestExtractDMARCSubdomainPolicy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
record string record string
expectedPolicy *string expectedSPF string
expectedDKIM string
}{ }{
{ {
name: "Subdomain policy - none", name: "SPF strict, DKIM relaxed",
record: "v=DMARC1; p=quarantine; sp=none", record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedPolicy: utils.PtrTo("none"), expectedSPF: "strict",
expectedDKIM: "relaxed",
}, },
{ {
name: "Subdomain policy - quarantine", name: "SPF relaxed explicit, DKIM strict",
record: "v=DMARC1; p=reject; sp=quarantine", record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedPolicy: utils.PtrTo("quarantine"), expectedSPF: "relaxed",
expectedDKIM: "strict",
}, },
{ {
name: "Subdomain policy - reject", name: "Defaults when neither specified",
record: "v=DMARC1; p=quarantine; sp=reject", record: "v=DMARC1; p=quarantine",
expectedPolicy: utils.PtrTo("reject"), expectedSPF: "relaxed",
expectedDKIM: "relaxed",
}, },
{ {
name: "No subdomain policy specified (defaults to main policy)", name: "Both strict in complex record",
record: "v=DMARC1; p=quarantine", record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
expectedPolicy: nil, expectedSPF: "strict",
}, expectedDKIM: "strict",
{
name: "Complex record with subdomain policy",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
expectedPolicy: utils.PtrTo("quarantine"),
}, },
} }
@ -257,86 +474,117 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSubdomainPolicy(tt.record) rec := analyzer.parseDMARCRecord("example.com", tt.record)
if tt.expectedPolicy == nil { if rec.SpfAlignment == nil {
if result != nil { t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record)
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) }
if string(*rec.SpfAlignment) != tt.expectedSPF {
t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF)
}
if rec.DkimAlignment == nil {
t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record)
}
if string(*rec.DkimAlignment) != tt.expectedDKIM {
t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM)
}
})
}
}
func TestParseDMARCRecordSubdomainPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedSP *string
expectedNP *string
}{
{
name: "sp=none, no np",
record: "v=DMARC1; p=quarantine; sp=none",
expectedSP: utils.PtrTo("none"),
expectedNP: nil,
},
{
name: "sp=reject, np=reject",
record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100",
expectedSP: utils.PtrTo("quarantine"),
expectedNP: utils.PtrTo("reject"),
},
{
name: "No sp or np (both default)",
record: "v=DMARC1; p=quarantine",
expectedSP: nil,
expectedNP: nil,
},
{
name: "np=quarantine, no sp",
record: "v=DMARC1; p=reject; np=quarantine",
expectedSP: nil,
expectedNP: utils.PtrTo("quarantine"),
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := analyzer.parseDMARCRecord("example.com", tt.record)
if tt.expectedSP == nil {
if rec.SubdomainPolicy != nil {
t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy)
} }
} else { } else {
if result == nil { if rec.SubdomainPolicy == nil {
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
} }
if string(*result) != *tt.expectedPolicy { if string(*rec.SubdomainPolicy) != *tt.expectedSP {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP)
}
}
if tt.expectedNP == nil {
if rec.NonexistentSubdomainPolicy != nil {
t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy)
}
} else {
if rec.NonexistentSubdomainPolicy == nil {
t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP)
}
if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP {
t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP)
} }
} }
}) })
} }
} }
func TestExtractDMARCPercentage(t *testing.T) { func TestParseDMARCRecordPercentage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
record string record string
expectedPercentage *int expectedPercentage *int
}{ }{
{ {name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)},
name: "Percentage - 100", {name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)},
record: "v=DMARC1; p=quarantine; pct=100", {name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)},
expectedPercentage: utils.PtrTo(100), {name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil},
}, {name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil},
{
name: "Percentage - 50",
record: "v=DMARC1; p=quarantine; pct=50",
expectedPercentage: utils.PtrTo(50),
},
{
name: "Percentage - 25",
record: "v=DMARC1; p=reject; pct=25",
expectedPercentage: utils.PtrTo(25),
},
{
name: "Percentage - 0",
record: "v=DMARC1; p=none; pct=0",
expectedPercentage: utils.PtrTo(0),
},
{
name: "No percentage specified (defaults to 100)",
record: "v=DMARC1; p=quarantine",
expectedPercentage: nil,
},
{
name: "Complex record with percentage",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
expectedPercentage: utils.PtrTo(75),
},
{
name: "Invalid percentage > 100 (ignored)",
record: "v=DMARC1; p=quarantine; pct=150",
expectedPercentage: nil,
},
{
name: "Invalid percentage < 0 (ignored)",
record: "v=DMARC1; p=quarantine; pct=-10",
expectedPercentage: nil,
},
} }
analyzer := NewDNSAnalyzer(5 * time.Second) analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPercentage(tt.record) result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage
if tt.expectedPercentage == nil { if tt.expectedPercentage == nil {
if result != nil { if result != nil {
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result)
} }
} else { } else {
if result == nil { if result == nil {
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage)
} }
if *result != *tt.expectedPercentage { if *result != *tt.expectedPercentage {
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage)
} }
} }
}) })

View file

@ -388,7 +388,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
if domain != "" { if domain != "" {
alignment.FromDomain = &domain alignment.FromDomain = &domain
// Extract organizational domain // Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain) orgDomain := getOrganizationalDomain(domain)
alignment.FromOrgDomain = &orgDomain alignment.FromOrgDomain = &orgDomain
} }
} }
@ -400,7 +400,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
if domain != "" { if domain != "" {
alignment.ReturnPathDomain = &domain alignment.ReturnPathDomain = &domain
// Extract organizational domain // Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain) orgDomain := getOrganizationalDomain(domain)
alignment.ReturnPathOrgDomain = &orgDomain alignment.ReturnPathOrgDomain = &orgDomain
} }
} }
@ -411,7 +411,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
for _, dkim := range *authResults.Dkim { for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" { if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain) orgDomain := getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{ dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
Domain: domain, Domain: domain,
OrgDomain: orgDomain, OrgDomain: orgDomain,
@ -542,7 +542,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name // getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs. // using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk // For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { func getOrganizationalDomain(domain string) string {
domain = strings.ToLower(strings.TrimSpace(domain)) domain = strings.ToLower(strings.TrimSpace(domain))
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)

View file

@ -318,7 +318,7 @@ func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bo
return 100, "" return 100, ""
} }
if results.ListedCount <= 0 { if results.ListedCount <= 0 || scoringListCount <= 0 {
return 100, "A+" return 100, "A+"
} }

View file

@ -291,34 +291,38 @@ func TestGetBlacklistScore(t *testing.T) {
{ {
name: "Listed on 1 RBL", name: "Listed on 1 RBL",
results: &DNSListResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 1, ListedCount: 1,
RelevantListedCount: 1,
}, },
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
}, },
{ {
name: "Listed on 2 RBLs", name: "Listed on 2 RBLs",
results: &DNSListResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 2, ListedCount: 2,
RelevantListedCount: 2,
}, },
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) expectedScore: 84, // 100 - 2*100/12 = 84
}, },
{ {
name: "Listed on 3 RBLs", name: "Listed on 3 RBLs",
results: &DNSListResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 3, ListedCount: 3,
RelevantListedCount: 3,
}, },
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) expectedScore: 75, // 100 - 3*100/12 = 75
}, },
{ {
name: "Listed on 4+ RBLs", name: "Listed on 4+ RBLs",
results: &DNSListResults{ results: &DNSListResults{
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 4, ListedCount: 4,
RelevantListedCount: 4,
}, },
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) expectedScore: 67, // 100 - 4*100/12 = 67
}, },
} }
@ -326,7 +330,7 @@ func TestGetBlacklistScore(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
score, _ := checker.CalculateScore(tt.results) score, _ := checker.CalculateScore(tt.results, false)
if score != tt.expectedScore { if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
} }

1169
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,31 @@
interface Props { interface Props {
dmarcRecord?: DmarcRecord; dmarcRecord?: DmarcRecord;
fromDomain?: string;
} }
let { dmarcRecord }: Props = $props(); let { dmarcRecord, fromDomain }: Props = $props();
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 // Helper function to determine policy strength
const policyStrength = (policy: string | undefined): number => { const policyStrength = (policy: string | undefined): number => {
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 }; const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
return strength[policy || "none"] || 0; 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> </script>
{#if dmarcRecord} {#if dmarcRecord}
@ -52,6 +68,27 @@
{/if} {/if}
</div> </div>
<!-- Fallback domain notice -->
{#if isFallback}
<div class="mb-3">
<strong>Record found at:</strong>
<code>{dmarcRecord.domain}</code>
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
No DMARC record exists for <code>{fromDomain}</code>. The record above was
inherited from
{#if isPsdFallback}
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> via the
DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain
fallback).
{/if}
</div>
</div>
{/if}
<!-- Policy --> <!-- Policy -->
{#if dmarcRecord.policy} {#if dmarcRecord.policy}
<div class="mb-3"> <div class="mb-3">
@ -99,6 +136,53 @@
</div> </div>
{/if} {/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 --> <!-- Subdomain Policy -->
{#if dmarcRecord.subdomain_policy} {#if dmarcRecord.subdomain_policy}
{@const mainStrength = policyStrength(dmarcRecord.policy)} {@const mainStrength = policyStrength(dmarcRecord.policy)}
@ -142,7 +226,43 @@
</div> </div>
{/if} {/if}
<!-- Percentage --> <!-- 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 (pct=, deprecated in DMARCbis) -->
{#if dmarcRecord.percentage !== undefined} {#if dmarcRecord.percentage !== undefined}
<div class="mb-3"> <div class="mb-3">
<strong>Enforcement Percentage:</strong> <strong>Enforcement Percentage:</strong>
@ -155,25 +275,35 @@
> >
{dmarcRecord.percentage}% {dmarcRecord.percentage}%
</span> </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} {#if dmarcRecord.percentage === 100}
<div class="alert alert-success mt-2 mb-0 small"> <div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i> <i class="bi bi-check-circle me-1"></i>
<strong>Full enforcement</strong> — all messages are subject to DMARC policy. <strong>Full enforcement</strong> — all messages are subject to DMARC policy.
This provides maximum protection.
</div> </div>
{:else if dmarcRecord.percentage >= 50} {:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
<div class="alert alert-warning mt-2 mb-0 small"> <div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i> <i class="bi bi-exclamation-triangle me-1"></i>
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of <strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
messages are subject to DMARC policy. Consider increasing to messages are subject to DMARC policy. Receivers ignoring pct= will apply
<code>pct=100</code> once you've validated your configuration. the full policy regardless.
</div> </div>
{:else} {:else if dmarcRecord.percentage > 0}
<div class="alert alert-danger mt-2 mb-0 small"> <div class="alert alert-danger mt-2 mb-0 small">
<i class="bi bi-x-circle me-1"></i> <i class="bi bi-x-circle me-1"></i>
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of <strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
messages are protected. Gradually increase to <code>pct=100</code> for full messages are protected. Receivers ignoring pct= will apply full policy.
protection.
</div> </div>
{/if} {/if}
</div> </div>
@ -259,6 +389,30 @@
</div> </div>
{/if} {/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 --> <!-- Error -->
{#if dmarcRecord.error} {#if dmarcRecord.error}
<div class="text-danger"> <div class="text-danger">

View file

@ -165,7 +165,10 @@
{/if} {/if}
<!-- DMARC Record --> <!-- DMARC Record -->
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} /> <DmarcRecordDisplay
dmarcRecord={dnsResults.dmarc_record}
fromDomain={dnsResults.from_domain}
/>
<!-- BIMI Record --> <!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} /> <BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />