Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3b1a094de | |||
| 809bca02e4 | |||
| 1b8627ef86 | |||
| 369a13526f | |||
| 3161e392e8 | |||
| 1516991057 | |||
| 0de67af847 | |||
| e324e6cbf9 |
19 changed files with 1825 additions and 975 deletions
|
|
@ -873,6 +873,24 @@ components:
|
|||
type: string
|
||||
description: DKIM record content
|
||||
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:
|
||||
type: boolean
|
||||
description: Whether the DKIM record is valid
|
||||
|
|
@ -891,6 +909,10 @@ components:
|
|||
type: string
|
||||
description: DMARC record content
|
||||
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:
|
||||
type: string
|
||||
enum: [none, quarantine, reject, unknown]
|
||||
|
|
@ -901,12 +923,38 @@ components:
|
|||
enum: [none, quarantine, reject, unknown]
|
||||
description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy
|
||||
example: "quarantine"
|
||||
nonexistent_subdomain_policy:
|
||||
type: string
|
||||
enum: [none, quarantine, reject, unknown]
|
||||
description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis)
|
||||
example: "reject"
|
||||
percentage:
|
||||
type: integer
|
||||
minimum: 0
|
||||
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
|
||||
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:
|
||||
type: string
|
||||
enum: [relaxed, strict]
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -8,7 +8,7 @@ require (
|
|||
github.com/getkin/kin-openapi v0.138.0
|
||||
github.com/gin-gonic/gin v1.12.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
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -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/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/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
||||
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||
github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
|
||||
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/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
|
||||
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
|
||||
|
|
|
|||
|
|
@ -202,6 +202,9 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
|
|||
if dns.DmarcRecord.SubdomainPolicy != nil {
|
||||
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
||||
}
|
||||
if dns.DmarcRecord.NonexistentSubdomainPolicy != nil {
|
||||
fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
if dns.DmarcRecord.Record != nil {
|
||||
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
||||
|
|
|
|||
|
|
@ -174,9 +174,7 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut
|
|||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||
|
||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
||||
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
|
||||
score += 5 * (xAlignedScore - 100) / 100
|
||||
}
|
||||
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
||||
|
||||
// Ensure score doesn't exceed 100
|
||||
if score > 100 {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
Result: model.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
||||
expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30
|
||||
},
|
||||
{
|
||||
name: "SPF and DKIM only",
|
||||
|
|
@ -59,7 +59,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
{Result: model.AuthResultResultPass},
|
||||
},
|
||||
},
|
||||
expectedScore: 48, // SPF=25 + DKIM=23
|
||||
expectedScore: 60, // SPF=30 + DKIM=30
|
||||
},
|
||||
{
|
||||
name: "SPF fail, DKIM pass",
|
||||
|
|
@ -71,7 +71,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
{Result: model.AuthResultResultPass},
|
||||
},
|
||||
},
|
||||
expectedScore: 23, // SPF=0 + DKIM=23
|
||||
expectedScore: 30, // SPF=0 + DKIM=30
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
|
|
@ -80,7 +80,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
Result: model.AuthResultResultSoftfail,
|
||||
},
|
||||
},
|
||||
expectedScore: 4,
|
||||
expectedScore: 5, // 30 * 17 / 100 = 5
|
||||
},
|
||||
{
|
||||
name: "No authentication",
|
||||
|
|
@ -97,7 +97,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
Result: model.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedScore: 35, // SPF (25) + BIMI (10)
|
||||
expectedScore: 40, // SPF (30) + BIMI (10)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,16 +51,16 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe
|
|||
if results.XAlignedFrom != nil {
|
||||
switch results.XAlignedFrom.Result {
|
||||
case model.AuthResultResultPass:
|
||||
// pass: positive contribution
|
||||
return 100
|
||||
// pass: no impact
|
||||
return 0
|
||||
case model.AuthResultResultFail:
|
||||
// fail: negative contribution
|
||||
return 0
|
||||
return -100
|
||||
default:
|
||||
// neutral, none, etc.: no impact
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return 100
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,18 +92,18 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
|||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "pass result gives positive score",
|
||||
name: "pass result gives no penalty",
|
||||
result: &model.AuthResult{
|
||||
Result: model.AuthResultResultPass,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "fail result gives zero score",
|
||||
name: "fail result gives full penalty",
|
||||
result: &model.AuthResult{
|
||||
Result: model.AuthResultResultFail,
|
||||
},
|
||||
expectedScore: 0,
|
||||
expectedScore: -100,
|
||||
},
|
||||
{
|
||||
name: "neutral result gives zero score",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
|||
|
||||
// Check DKIM records by parsing DKIM-Signature headers directly
|
||||
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
|
||||
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||
dkimRecord := d.checkDKIMRecord(sig)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]model.DKIMRecord)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ package analyzer
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
|
|
@ -30,17 +32,18 @@ import (
|
|||
"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 {
|
||||
Domain string
|
||||
Selector string
|
||||
Domain 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 {
|
||||
var results []DKIMHeader
|
||||
for _, sig := range signatures {
|
||||
var domain, selector string
|
||||
var domain, selector, algorithm string
|
||||
for _, part := range strings.Split(sig, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
|
|
@ -53,19 +56,61 @@ func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
|||
domain = val
|
||||
case "s":
|
||||
selector = val
|
||||
case "a":
|
||||
algorithm = val
|
||||
}
|
||||
}
|
||||
if domain != "" && selector != "" {
|
||||
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
|
||||
results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
|
||||
// DKIM records are at: selector._domainkey.domain
|
||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||
// parseDKIMTags splits a DKIM DNS record into a tag→value map.
|
||||
func parseDKIMTags(record string) map[string]string {
|
||||
tags := make(map[string]string)
|
||||
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)
|
||||
defer cancel()
|
||||
|
|
@ -73,53 +118,83 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
|
|||
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
||||
if err != nil {
|
||||
return &model.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||
Selector: h.Selector,
|
||||
Domain: h.Domain,
|
||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
||||
Valid: false,
|
||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||
}
|
||||
}
|
||||
|
||||
if len(txtRecords) == 0 {
|
||||
return &model.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("No DKIM record found"),
|
||||
Selector: h.Selector,
|
||||
Domain: h.Domain,
|
||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("No DKIM record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all TXT record parts (DKIM can be split)
|
||||
dkimRecord := strings.Join(txtRecords, "")
|
||||
|
||||
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
||||
if !d.validateDKIM(dkimRecord) {
|
||||
return &model.DKIMRecord{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: utils.PtrTo(dkimRecord),
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("DKIM record appears malformed"),
|
||||
Selector: h.Selector,
|
||||
Domain: h.Domain,
|
||||
Record: utils.PtrTo(dkimRecord),
|
||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
||||
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{
|
||||
Selector: selector,
|
||||
Domain: domain,
|
||||
Record: &dkimRecord,
|
||||
Valid: true,
|
||||
Selector: h.Selector,
|
||||
Domain: h.Domain,
|
||||
Record: &dkimRecord,
|
||||
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 {
|
||||
// Should contain p= tag (public key)
|
||||
if !strings.Contains(record, "p=") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Often contains v=DKIM1 but not required
|
||||
// If v= is present, it should be DKIM1
|
||||
// If v= is present, it must be DKIM1
|
||||
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
||||
return false
|
||||
}
|
||||
|
|
@ -128,21 +203,57 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
|||
}
|
||||
|
||||
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
|
||||
// DKIM provides strong email authentication
|
||||
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
||||
hasValidDKIM := false
|
||||
for _, dkim := range *results.DkimRecords {
|
||||
if dkim.Valid {
|
||||
hasValidDKIM = true
|
||||
break
|
||||
if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
hasValid := false
|
||||
for _, dkim := range *results.DkimRecords {
|
||||
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
|
||||
} else {
|
||||
// Partial credit if DKIM record exists but has issues
|
||||
score += 25
|
||||
|
||||
// Key size penalties apply only to RSA
|
||||
keyType := ""
|
||||
if dkim.KeyType != nil {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -47,56 +51,56 @@ func TestParseDKIMSignatures(t *testing.T) {
|
|||
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==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
|
||||
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}},
|
||||
},
|
||||
{
|
||||
name: "Microsoft 365 style",
|
||||
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==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
|
||||
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
|
||||
},
|
||||
{
|
||||
name: "Tab-folded multiline (Postfix-style)",
|
||||
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==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
|
||||
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}},
|
||||
},
|
||||
{
|
||||
name: "Space-folded multiline (RFC-style)",
|
||||
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==",
|
||||
},
|
||||
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",
|
||||
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==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
|
||||
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
|
||||
},
|
||||
{
|
||||
name: "No space after semicolons",
|
||||
signatures: []string{
|
||||
`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",
|
||||
signatures: []string{
|
||||
`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)",
|
||||
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==",
|
||||
},
|
||||
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)",
|
||||
|
|
@ -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==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "mydomain.com", Selector: "mail"},
|
||||
{Domain: "sendib.com", Selector: "mail"},
|
||||
{Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"},
|
||||
{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==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "football.example.com", Selector: "brisbane"},
|
||||
{Domain: "football.example.com", Selector: "test"},
|
||||
{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"},
|
||||
{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==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
|
||||
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
|
||||
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"},
|
||||
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -136,56 +140,56 @@ func TestParseDKIMSignatures(t *testing.T) {
|
|||
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==`,
|
||||
},
|
||||
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",
|
||||
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==`,
|
||||
},
|
||||
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)",
|
||||
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==`,
|
||||
},
|
||||
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",
|
||||
signatures: []string{
|
||||
`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",
|
||||
signatures: []string{
|
||||
`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=",
|
||||
signatures: []string{
|
||||
`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",
|
||||
signatures: []string{
|
||||
`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",
|
||||
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==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
|
||||
},
|
||||
{
|
||||
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==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "good.com", Selector: "sel1"},
|
||||
{Domain: "also-good.com", Selector: "sel2"},
|
||||
{Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"},
|
||||
{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 {
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -24,233 +24,290 @@ package analyzer
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/model"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
)
|
||||
|
||||
// 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)
|
||||
var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||
|
||||
// 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)
|
||||
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 {
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
if foundDomain == "" {
|
||||
return &model.DMARCRecord{
|
||||
Valid: false,
|
||||
Error: utils.PtrTo("No DMARC record found"),
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
return d.parseDMARCRecord(foundDomain, raw)
|
||||
}
|
||||
|
||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||
// 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]
|
||||
// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent.
|
||||
// Used during DNS Tree Walk before full record parsing.
|
||||
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
|
||||
v := parseDKIMTags(record)["psd"]
|
||||
switch v {
|
||||
case "y", "n", "u":
|
||||
return v
|
||||
}
|
||||
return "unknown"
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
// validateDMARC performs basic DMARC record validation.
|
||||
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
|
||||
// rua= but no p= is treated as p=none and considered valid.
|
||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||
// Must start with v=DMARC1
|
||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||
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=") {
|
||||
return false
|
||||
return strings.Contains(record, "rua=")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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.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 == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Policy strength: none < quarantine < reject
|
||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||
|
||||
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
|
||||
if !results.DmarcRecord.Valid {
|
||||
if results.DmarcRecord.Record != nil {
|
||||
// Partial credit if a DMARC record exists but has issues
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,14 +22,206 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/model"
|
||||
"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 {
|
||||
name string
|
||||
record string
|
||||
|
|
@ -61,9 +253,135 @@ func TestExtractDMARCPolicy(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDMARCPolicy(tt.record)
|
||||
if result != tt.expectedPolicy {
|
||||
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
|
||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
||||
if rec.Policy == nil {
|
||||
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",
|
||||
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",
|
||||
record: "p=quarantine",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid DMARC - no policy",
|
||||
name: "Invalid DMARC - no policy and no rua",
|
||||
record: "v=DMARC1",
|
||||
expected: false,
|
||||
},
|
||||
|
|
@ -114,142 +437,36 @@ func TestValidateDMARC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExtractDMARCSPFAlignment(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) {
|
||||
func TestParseDMARCRecordAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
expectedPolicy *string
|
||||
expectedSPF string
|
||||
expectedDKIM string
|
||||
}{
|
||||
{
|
||||
name: "Subdomain policy - none",
|
||||
record: "v=DMARC1; p=quarantine; sp=none",
|
||||
expectedPolicy: utils.PtrTo("none"),
|
||||
name: "SPF strict, DKIM relaxed",
|
||||
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
||||
expectedSPF: "strict",
|
||||
expectedDKIM: "relaxed",
|
||||
},
|
||||
{
|
||||
name: "Subdomain policy - quarantine",
|
||||
record: "v=DMARC1; p=reject; sp=quarantine",
|
||||
expectedPolicy: utils.PtrTo("quarantine"),
|
||||
name: "SPF relaxed explicit, DKIM strict",
|
||||
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
||||
expectedSPF: "relaxed",
|
||||
expectedDKIM: "strict",
|
||||
},
|
||||
{
|
||||
name: "Subdomain policy - reject",
|
||||
record: "v=DMARC1; p=quarantine; sp=reject",
|
||||
expectedPolicy: utils.PtrTo("reject"),
|
||||
name: "Defaults when neither specified",
|
||||
record: "v=DMARC1; p=quarantine",
|
||||
expectedSPF: "relaxed",
|
||||
expectedDKIM: "relaxed",
|
||||
},
|
||||
{
|
||||
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"),
|
||||
name: "Both strict in complex record",
|
||||
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
|
||||
expectedSPF: "strict",
|
||||
expectedDKIM: "strict",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -257,86 +474,117 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
|||
|
||||
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)
|
||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
||||
if rec.SpfAlignment == nil {
|
||||
t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record)
|
||||
}
|
||||
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 {
|
||||
if result == nil {
|
||||
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
|
||||
if rec.SubdomainPolicy == nil {
|
||||
t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
|
||||
}
|
||||
if string(*result) != *tt.expectedPolicy {
|
||||
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
|
||||
if string(*rec.SubdomainPolicy) != *tt.expectedSP {
|
||||
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 {
|
||||
name string
|
||||
record string
|
||||
expectedPercentage *int
|
||||
}{
|
||||
{
|
||||
name: "Percentage - 100",
|
||||
record: "v=DMARC1; p=quarantine; pct=100",
|
||||
expectedPercentage: utils.PtrTo(100),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{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: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)},
|
||||
{name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil},
|
||||
{name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
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 result != nil {
|
||||
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
|
||||
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
|||
if domain != "" {
|
||||
alignment.FromDomain = &domain
|
||||
// Extract organizational domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
orgDomain := getOrganizationalDomain(domain)
|
||||
alignment.FromOrgDomain = &orgDomain
|
||||
}
|
||||
}
|
||||
|
|
@ -400,7 +400,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
|||
if domain != "" {
|
||||
alignment.ReturnPathDomain = &domain
|
||||
// Extract organizational domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
orgDomain := getOrganizationalDomain(domain)
|
||||
alignment.ReturnPathOrgDomain = &orgDomain
|
||||
}
|
||||
}
|
||||
|
|
@ -411,7 +411,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
|||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||
domain := *dkim.Domain
|
||||
orgDomain := h.getOrganizationalDomain(domain)
|
||||
orgDomain := getOrganizationalDomain(domain)
|
||||
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
||||
Domain: domain,
|
||||
OrgDomain: orgDomain,
|
||||
|
|
@ -542,7 +542,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
|||
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
||||
// 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
|
||||
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
||||
func getOrganizationalDomain(domain string) string {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
|
||||
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bo
|
|||
return 100, ""
|
||||
}
|
||||
|
||||
if results.ListedCount <= 0 {
|
||||
if results.ListedCount <= 0 || scoringListCount <= 0 {
|
||||
return 100, "A+"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -291,34 +291,38 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
IPsChecked: []string{"198.51.100.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",
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
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",
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 3,
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
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",
|
||||
results: &DNSListResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := checker.CalculateScore(tt.results)
|
||||
score, _ := checker.CalculateScore(tt.results, false)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
|
|||
1169
web/package-lock.json
generated
1169
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,15 +3,31 @@
|
|||
|
||||
interface Props {
|
||||
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
|
||||
const policyStrength = (policy: string | undefined): number => {
|
||||
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
||||
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>
|
||||
|
||||
{#if dmarcRecord}
|
||||
|
|
@ -52,6 +68,27 @@
|
|||
{/if}
|
||||
</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 -->
|
||||
{#if dmarcRecord.policy}
|
||||
<div class="mb-3">
|
||||
|
|
@ -99,6 +136,53 @@
|
|||
</div>
|
||||
{/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 -->
|
||||
{#if dmarcRecord.subdomain_policy}
|
||||
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
||||
|
|
@ -142,7 +226,43 @@
|
|||
</div>
|
||||
{/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}
|
||||
<div class="mb-3">
|
||||
<strong>Enforcement Percentage:</strong>
|
||||
|
|
@ -155,25 +275,35 @@
|
|||
>
|
||||
{dmarcRecord.percentage}%
|
||||
</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}
|
||||
<div class="alert alert-success mt-2 mb-0 small">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
||||
This provides maximum protection.
|
||||
</div>
|
||||
{:else if dmarcRecord.percentage >= 50}
|
||||
{:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
|
||||
<div class="alert alert-warning mt-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are subject to DMARC policy. Consider increasing to
|
||||
<code>pct=100</code> once you've validated your configuration.
|
||||
messages are subject to DMARC policy. Receivers ignoring pct= will apply
|
||||
the full policy regardless.
|
||||
</div>
|
||||
{:else}
|
||||
{:else if dmarcRecord.percentage > 0}
|
||||
<div class="alert alert-danger mt-2 mb-0 small">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||
messages are protected. Gradually increase to <code>pct=100</code> for full
|
||||
protection.
|
||||
messages are protected. Receivers ignoring pct= will apply full policy.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -259,6 +389,30 @@
|
|||
</div>
|
||||
{/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 -->
|
||||
{#if dmarcRecord.error}
|
||||
<div class="text-danger">
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@
|
|||
{/if}
|
||||
|
||||
<!-- DMARC Record -->
|
||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||
<DmarcRecordDisplay
|
||||
dmarcRecord={dnsResults.dmarc_record}
|
||||
fromDomain={dnsResults.from_domain}
|
||||
/>
|
||||
|
||||
<!-- BIMI Record -->
|
||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue