Compare commits

..

No commits in common. "master" and "build" have entirely different histories.

19 changed files with 975 additions and 1825 deletions

View file

@ -873,24 +873,6 @@ 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
@ -909,10 +891,6 @@ 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]
@ -923,38 +901,12 @@ 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). DEPRECATED in DMARCbis: use test_mode (t=y) instead." description: Percentage of messages subjected to filtering (pct tag, default 100)
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.4.0 github.com/oapi-codegen/runtime v1.3.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.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
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,9 +202,6 @@ 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,7 +174,9 @@ 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)
score += 5 * a.calculateXAlignedFromScore(results) / 100 if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 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: 90, // SPF=30 + DKIM=30 + DMARC=30 expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
}, },
{ {
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: 60, // SPF=30 + DKIM=30 expectedScore: 48, // SPF=25 + DKIM=23
}, },
{ {
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: 30, // SPF=0 + DKIM=30 expectedScore: 23, // SPF=0 + DKIM=23
}, },
{ {
name: "SPF softfail", name: "SPF softfail",
@ -80,7 +80,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: model.AuthResultResultSoftfail, Result: model.AuthResultResultSoftfail,
}, },
}, },
expectedScore: 5, // 30 * 17 / 100 = 5 expectedScore: 4,
}, },
{ {
name: "No authentication", name: "No authentication",
@ -97,7 +97,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: model.AuthResultResultPass, Result: model.AuthResultResultPass,
}, },
}, },
expectedScore: 40, // SPF (30) + BIMI (10) expectedScore: 35, // SPF (25) + 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: no impact // pass: positive contribution
return 0 return 100
case model.AuthResultResultFail: case model.AuthResultResultFail:
// fail: negative contribution // fail: negative contribution
return -100 return 0
default: default:
// neutral, none, etc.: no impact // neutral, none, etc.: no impact
return 0 return 0
} }
} }
return 0 return 100
} }

View file

@ -92,18 +92,18 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
expectedScore int expectedScore int
}{ }{
{ {
name: "pass result gives no penalty", name: "pass result gives positive score",
result: &model.AuthResult{ result: &model.AuthResult{
Result: model.AuthResultResultPass, Result: model.AuthResultResultPass,
}, },
expectedScore: 0, expectedScore: 100,
}, },
{ {
name: "fail result gives full penalty", name: "fail result gives zero score",
result: &model.AuthResult{ result: &model.AuthResult{
Result: model.AuthResultResultFail, Result: model.AuthResultResultFail,
}, },
expectedScore: -100, expectedScore: 0,
}, },
{ {
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) dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
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,8 +23,6 @@ package analyzer
import ( import (
"context" "context"
"crypto/x509"
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
@ -32,18 +30,17 @@ import (
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
) )
// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header. // DKIMHeader holds the domain and selector extracted 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, selector and algorithm from DKIM-Signature header values. // parseDKIMSignatures extracts domain and selector 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, algorithm string var domain, selector 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 {
@ -56,61 +53,19 @@ 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, Algorithm: algorithm}) results = append(results, DKIMHeader{Domain: domain, Selector: selector})
} }
} }
return results return results
} }
// parseDKIMTags splits a DKIM DNS record into a tag→value map. // checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
func parseDKIMTags(record string) map[string]string { func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
tags := make(map[string]string) // DKIM records are at: selector._domainkey.domain
for _, part := range strings.Split(record, ";") { dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
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()
@ -118,83 +73,53 @@ func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *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: h.Selector, Selector: selector,
Domain: h.Domain, Domain: domain,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), Valid: false,
Valid: false, Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
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: h.Selector, Selector: selector,
Domain: h.Domain, Domain: domain,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), Valid: false,
Valid: false, Error: utils.PtrTo("No DKIM record found"),
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: h.Selector, Selector: selector,
Domain: h.Domain, Domain: domain,
Record: utils.PtrTo(dkimRecord), Record: utils.PtrTo(dkimRecord),
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), Valid: false,
Valid: false, Error: utils.PtrTo("DKIM record appears malformed"),
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: h.Selector, Selector: selector,
Domain: h.Domain, Domain: domain,
Record: &dkimRecord, Record: &dkimRecord,
KeyType: utils.PtrTo(keyType), Valid: true,
HashAlgorithms: &hashAlgorithms,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
KeySize: parseKeySize(keyType, tags["p"]),
Valid: true,
} }
} }
func signingAlgorithmPtr(a string) *string { // validateDKIM performs basic DKIM record validation
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
} }
// If v= is present, it must be DKIM1 // Often contains v=DKIM1 but not required
// 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
} }
@ -203,57 +128,21 @@ 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) {
if results.DkimRecords == nil || len(*results.DkimRecords) == 0 { // DKIM provides strong email authentication
return 0 if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
} hasValidDKIM := false
for _, dkim := range *results.DkimRecords {
hasValid := false if dkim.Valid {
for _, dkim := range *results.DkimRecords { hasValidDKIM = true
if dkim.Valid { break
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 {
// Key size penalties apply only to RSA score += 100
keyType := "" } else {
if dkim.KeyType != nil { // Partial credit if DKIM record exists but has issues
keyType = strings.ToLower(*dkim.KeyType) score += 25
} }
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,10 +22,6 @@
package analyzer package analyzer
import ( import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"testing" "testing"
"time" "time"
) )
@ -51,56 +47,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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
}, },
{ {
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", Algorithm: "ed25519-sha256"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
}, },
{ {
name: "Multiple signatures (ESP double-signing)", name: "Multiple signatures (ESP double-signing)",
@ -109,8 +105,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", Algorithm: "rsa-sha256"}, {Domain: "mydomain.com", Selector: "mail"},
{Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"}, {Domain: "sendib.com", Selector: "mail"},
}, },
}, },
{ {
@ -120,8 +116,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", Algorithm: "ed25519-sha256"}, {Domain: "football.example.com", Selector: "brisbane"},
{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}, {Domain: "football.example.com", Selector: "test"},
}, },
}, },
{ {
@ -131,8 +127,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", Algorithm: "rsa-sha256"}, {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"}, {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
}, },
}, },
{ {
@ -140,56 +136,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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
}, },
{ {
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", Algorithm: "rsa-sha1"}}, expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
}, },
{ {
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", Algorithm: "rsa-sha256"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
}, },
{ {
name: "Missing d= tag", name: "Missing d= tag",
@ -220,8 +216,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", Algorithm: "rsa-sha256"}, {Domain: "good.com", Selector: "sel1"},
{Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"}, {Domain: "also-good.com", Selector: "sel2"},
}, },
}, },
} }
@ -239,9 +235,6 @@ 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)
}
} }
}) })
} }
@ -291,119 +284,3 @@ 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,290 +24,233 @@ package analyzer
import ( import (
"context" "context"
"fmt" "fmt"
"net" "regexp"
"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"
) )
var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2} // checkmodel.DMARCRecord looks up and validates DMARC record for a domain
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, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain)) txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
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)
}
// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent. // Extract policy
// Used during DNS Tree Walk before full record parsing. policy := d.extractDMARCPolicy(dmarcRecord)
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
v := parseDKIMTags(record)["psd"] // Extract subdomain policy
switch v { subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord)
case "y", "n", "u":
return v // 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,
} }
return ""
} }
// validateDMARC performs basic DMARC record validation. // extractDMARCPolicy extracts the policy from a DMARC record
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// rua= but no p= is treated as p=none and considered valid. // Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment {
// 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
} }
// p= absent is allowed in DMARCbis when rua= is present (treated as p=none). // Must have a policy tag
if !strings.Contains(record, "p=") { if !strings.Contains(record, "p=") {
return strings.Contains(record, "rua=") return false
} }
return true return true
} }
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
if results.DmarcRecord == nil { // DMARC ties SPF and DKIM together and provides policy
return if results.DmarcRecord != nil {
} if results.DmarcRecord.Valid {
score += 50
// Bonus points for stricter policies
if results.DmarcRecord.Policy != nil {
switch *results.DmarcRecord.Policy {
case "reject":
// Strictest policy - full points already awarded
score += 25
case "quarantine":
// Good policy - no deduction
case "none":
// Weakest policy - deduct 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)
if !results.DmarcRecord.Valid { // Policy strength: none < quarantine < reject
if results.DmarcRecord.Record != nil { policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
// 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,206 +22,14 @@
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"
) )
// mockDNSResolver maps domain names to TXT records for testing. func TestExtractDMARCPolicy(t *testing.T) {
// 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
@ -253,135 +61,9 @@ func TestParseDMARCRecordPolicy(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) {
rec := analyzer.parseDMARCRecord("example.com", tt.record) result := analyzer.extractDMARCPolicy(tt.record)
if rec.Policy == nil { if result != tt.expectedPolicy {
t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record) t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
}
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)
} }
}) })
} }
@ -403,18 +85,13 @@ 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 and no rua", name: "Invalid DMARC - no policy",
record: "v=DMARC1", record: "v=DMARC1",
expected: false, expected: false,
}, },
@ -437,36 +114,41 @@ func TestValidateDMARC(t *testing.T) {
} }
} }
func TestParseDMARCRecordAlignment(t *testing.T) { func TestExtractDMARCSPFAlignment(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
record string record string
expectedSPF string expectedAlignment string
expectedDKIM string
}{ }{
{ {
name: "SPF strict, DKIM relaxed", name: "SPF alignment - strict",
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", record: "v=DMARC1; p=quarantine; aspf=s",
expectedSPF: "strict", expectedAlignment: "strict",
expectedDKIM: "relaxed",
}, },
{ {
name: "SPF relaxed explicit, DKIM strict", name: "SPF alignment - relaxed (explicit)",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", record: "v=DMARC1; p=quarantine; aspf=r",
expectedSPF: "relaxed", expectedAlignment: "relaxed",
expectedDKIM: "strict",
}, },
{ {
name: "Defaults when neither specified", name: "SPF alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=quarantine", record: "v=DMARC1; p=quarantine",
expectedSPF: "relaxed", expectedAlignment: "relaxed",
expectedDKIM: "relaxed",
}, },
{ {
name: "Both strict in complex record", name: "Both alignments specified - check SPF strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
expectedSPF: "strict", expectedAlignment: "strict",
expectedDKIM: "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",
}, },
} }
@ -474,53 +156,52 @@ func TestParseDMARCRecordAlignment(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) {
rec := analyzer.parseDMARCRecord("example.com", tt.record) result := analyzer.extractDMARCSPFAlignment(tt.record)
if rec.SpfAlignment == nil { if result == nil {
t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record) t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
} }
if string(*rec.SpfAlignment) != tt.expectedSPF { if string(*result) != tt.expectedAlignment {
t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF) t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
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) { func TestExtractDMARCDKIMAlignment(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
record string record string
expectedSP *string expectedAlignment string
expectedNP *string
}{ }{
{ {
name: "sp=none, no np", name: "DKIM alignment - strict",
record: "v=DMARC1; p=quarantine; sp=none", record: "v=DMARC1; p=reject; adkim=s",
expectedSP: utils.PtrTo("none"), expectedAlignment: "strict",
expectedNP: nil,
}, },
{ {
name: "sp=reject, np=reject", name: "DKIM alignment - relaxed (explicit)",
record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", record: "v=DMARC1; p=reject; adkim=r",
expectedSP: utils.PtrTo("quarantine"), expectedAlignment: "relaxed",
expectedNP: utils.PtrTo("reject"),
}, },
{ {
name: "No sp or np (both default)", name: "DKIM alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=quarantine", record: "v=DMARC1; p=none",
expectedSP: nil, expectedAlignment: "relaxed",
expectedNP: nil,
}, },
{ {
name: "np=quarantine, no sp", name: "Both alignments specified - check DKIM strict",
record: "v=DMARC1; p=reject; np=quarantine", record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedSP: nil, expectedAlignment: "strict",
expectedNP: utils.PtrTo("quarantine"), },
{
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",
}, },
} }
@ -528,63 +209,134 @@ func TestParseDMARCRecordSubdomainPolicy(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) {
rec := analyzer.parseDMARCRecord("example.com", tt.record) result := analyzer.extractDMARCDKIMAlignment(tt.record)
if tt.expectedSP == nil { if result == nil {
if rec.SubdomainPolicy != nil { t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy)
}
} else {
if rec.SubdomainPolicy == nil {
t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
}
if string(*rec.SubdomainPolicy) != *tt.expectedSP {
t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP)
}
} }
if tt.expectedNP == nil { if string(*result) != tt.expectedAlignment {
if rec.NonexistentSubdomainPolicy != nil { t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy) }
})
}
}
func TestExtractDMARCSubdomainPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy *string
}{
{
name: "Subdomain policy - none",
record: "v=DMARC1; p=quarantine; sp=none",
expectedPolicy: utils.PtrTo("none"),
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: utils.PtrTo("quarantine"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: utils.PtrTo("reject"),
},
{
name: "No subdomain policy specified (defaults to main policy)",
record: "v=DMARC1; p=quarantine",
expectedPolicy: nil,
},
{
name: "Complex record with subdomain policy",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
expectedPolicy: utils.PtrTo("quarantine"),
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSubdomainPolicy(tt.record)
if tt.expectedPolicy == nil {
if result != nil {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result)
} }
} else { } else {
if rec.NonexistentSubdomainPolicy == nil { if result == nil {
t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP) t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
} }
if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP { if string(*result) != *tt.expectedPolicy {
t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP) t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
} }
} }
}) })
} }
} }
func TestParseDMARCRecordPercentage(t *testing.T) { func TestExtractDMARCPercentage(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: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)}, name: "Percentage - 100",
{name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)}, record: "v=DMARC1; p=quarantine; pct=100",
{name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil}, expectedPercentage: utils.PtrTo(100),
{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.parseDMARCRecord("example.com", tt.record).Percentage result := analyzer.extractDMARCPercentage(tt.record)
if tt.expectedPercentage == nil { if tt.expectedPercentage == nil {
if result != nil { if result != nil {
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result) t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
} }
} else { } else {
if result == nil { if result == nil {
t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage) t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
} }
if *result != *tt.expectedPercentage { if *result != *tt.expectedPercentage {
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage) t.Errorf("extractDMARCPercentage(%q) = %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 := getOrganizationalDomain(domain) orgDomain := h.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 := getOrganizationalDomain(domain) orgDomain := h.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 := getOrganizationalDomain(domain) orgDomain := h.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 getOrganizationalDomain(domain string) string { func (h *HeaderAnalyzer) 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 || scoringListCount <= 0 { if results.ListedCount <= 0 {
return 100, "A+" return 100, "A+"
} }

View file

@ -291,38 +291,34 @@ 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: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational) expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
}, },
{ {
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: 84, // 100 - 2*100/12 = 84 expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
}, },
{ {
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: 75, // 100 - 3*100/12 = 75 expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
}, },
{ {
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: 67, // 100 - 4*100/12 = 67 expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
}, },
} }
@ -330,7 +326,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, false) score, _ := checker.CalculateScore(tt.results)
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,31 +3,15 @@
interface Props { interface Props {
dmarcRecord?: DmarcRecord; dmarcRecord?: DmarcRecord;
fromDomain?: string;
} }
let { dmarcRecord, fromDomain }: Props = $props(); let { dmarcRecord }: 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}
@ -68,27 +52,6 @@
{/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">
@ -136,53 +99,6 @@
</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)}
@ -226,43 +142,7 @@
</div> </div>
{/if} {/if}
<!-- Non-Existent Subdomain Policy (np tag, DMARCbis) --> <!-- Percentage -->
{#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>
@ -275,35 +155,25 @@
> >
{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 > 0 && dmarcRecord.percentage >= 50} {:else if 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. Receivers ignoring pct= will apply messages are subject to DMARC policy. Consider increasing to
the full policy regardless. <code>pct=100</code> once you've validated your configuration.
</div> </div>
{:else if dmarcRecord.percentage > 0} {:else}
<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. Receivers ignoring pct= will apply full policy. messages are protected. Gradually increase to <code>pct=100</code> for full
protection.
</div> </div>
{/if} {/if}
</div> </div>
@ -389,30 +259,6 @@
</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,10 +165,7 @@
{/if} {/if}
<!-- DMARC Record --> <!-- DMARC Record -->
<DmarcRecordDisplay <DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
dmarcRecord={dnsResults.dmarc_record}
fromDomain={dnsResults.from_domain}
/>
<!-- BIMI Record --> <!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} /> <BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />