dmarc: implement RFC 7489 org-domain fallback and RFC 9091 PSD DMARC
DMARC lookup now follows the full RFC 7489 §6.6.3 fallback chain: exact From domain → organizational domain (eTLD+1 via PSL) → public suffix domain (RFC 9091, only when psd=y is present). DNS errors abort immediately without triggering fallback; NXDOMAIN and missing v=DMARC1 records do trigger it. The found domain is exposed in the new DMARCRecord.domain field for reporting purposes. Also promote getOrganizationalDomain to a package-level function so both HeaderAnalyzer and DNSAnalyzer can share it, and fix pre-existing rbl_test.go compilation errors and stale score expectations. Closes: #98
This commit is contained in:
parent
0de67af847
commit
1516991057
7 changed files with 295 additions and 57 deletions
|
|
@ -891,6 +891,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: DMARC record content
|
description: DMARC record content
|
||||||
example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
|
example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used)
|
||||||
|
example: "example.com"
|
||||||
policy:
|
policy:
|
||||||
type: string
|
type: string
|
||||||
enum: [none, quarantine, reject, unknown]
|
enum: [none, quarantine, reject, unknown]
|
||||||
|
|
|
||||||
|
|
@ -24,62 +24,50 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain
|
// lookupDMARCAt queries _dmarc.<domain> and returns the raw DMARC1 TXT record.
|
||||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred.
|
||||||
// DMARC records are at: _dmarc.domain
|
func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) {
|
||||||
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain))
|
||||||
if err != nil {
|
if lookupErr != nil {
|
||||||
return &model.DMARCRecord{
|
if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound {
|
||||||
Valid: false,
|
return "", true, nil
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
|
||||||
}
|
}
|
||||||
|
return "", false, lookupErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find DMARC record (starts with "v=DMARC1")
|
|
||||||
var dmarcRecord string
|
|
||||||
for _, txt := range txtRecords {
|
for _, txt := range txtRecords {
|
||||||
if strings.HasPrefix(txt, "v=DMARC1") {
|
if strings.HasPrefix(txt, "v=DMARC1") {
|
||||||
dmarcRecord = txt
|
return txt, false, nil
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if dmarcRecord == "" {
|
// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model.
|
||||||
|
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
|
||||||
|
policy := d.extractDMARCPolicy(rawRecord)
|
||||||
|
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
||||||
|
percentage := d.extractDMARCPercentage(rawRecord)
|
||||||
|
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
||||||
|
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
||||||
|
|
||||||
|
if !d.validateDMARC(rawRecord) {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Domain: &foundDomain,
|
||||||
Error: utils.PtrTo("No DMARC record found"),
|
Record: &rawRecord,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
|
|
@ -91,7 +79,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Record: &dmarcRecord,
|
Domain: &foundDomain,
|
||||||
|
Record: &rawRecord,
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
|
|
@ -101,6 +90,55 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkDMARCRecord looks up and validates the DMARC record for a domain.
|
||||||
|
// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and
|
||||||
|
// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y).
|
||||||
|
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
|
// Step 1: try exact domain (_dmarc.<domain>)
|
||||||
|
raw, notFound, err := d.lookupDMARCAt(domain)
|
||||||
|
if err != nil {
|
||||||
|
return &model.DMARCRecord{
|
||||||
|
Valid: false,
|
||||||
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !notFound {
|
||||||
|
return d.parseDMARCRecord(domain, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1)
|
||||||
|
orgDomain := getOrganizationalDomain(domain)
|
||||||
|
if orgDomain != domain {
|
||||||
|
raw, notFound, err = d.lookupDMARCAt(orgDomain)
|
||||||
|
if err != nil {
|
||||||
|
return &model.DMARCRecord{
|
||||||
|
Valid: false,
|
||||||
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !notFound {
|
||||||
|
return d.parseDMARCRecord(orgDomain, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y
|
||||||
|
psd, _ := publicsuffix.PublicSuffix(domain)
|
||||||
|
if psd != "" && psd != orgDomain {
|
||||||
|
raw, notFound, err = d.lookupDMARCAt(psd)
|
||||||
|
if err == nil && !notFound {
|
||||||
|
// Only apply PSD DMARC when the record explicitly opts in with psd=y
|
||||||
|
if strings.Contains(raw, "psd=y") {
|
||||||
|
return d.parseDMARCRecord(psd, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.DMARCRecord{
|
||||||
|
Valid: false,
|
||||||
|
Error: utils.PtrTo("No DMARC record found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||||
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||||
// Look for p=none, p=quarantine, or p=reject
|
// Look for p=none, p=quarantine, or p=reject
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,178 @@
|
||||||
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.
|
||||||
|
// 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 — falls back to 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 — falls back to 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 has psd=y — RFC 9091 fallback",
|
||||||
|
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 — no record returned",
|
||||||
|
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 — no fallback, 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 — no redundant fallback",
|
||||||
|
domain: "example.com",
|
||||||
|
txt: map[string][]string{
|
||||||
|
"_dmarc.example.com": {orgRecord},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
wantDomain: utils.PtrTo("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 TestExtractDMARCPolicy(t *testing.T) {
|
func TestExtractDMARCPolicy(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
alignment.FromDomain = &domain
|
alignment.FromDomain = &domain
|
||||||
// Extract organizational domain
|
// Extract organizational domain
|
||||||
orgDomain := h.getOrganizationalDomain(domain)
|
orgDomain := getOrganizationalDomain(domain)
|
||||||
alignment.FromOrgDomain = &orgDomain
|
alignment.FromOrgDomain = &orgDomain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +400,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
alignment.ReturnPathDomain = &domain
|
alignment.ReturnPathDomain = &domain
|
||||||
// Extract organizational domain
|
// Extract organizational domain
|
||||||
orgDomain := h.getOrganizationalDomain(domain)
|
orgDomain := getOrganizationalDomain(domain)
|
||||||
alignment.ReturnPathOrgDomain = &orgDomain
|
alignment.ReturnPathOrgDomain = &orgDomain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +411,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
for _, dkim := range *authResults.Dkim {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||||
domain := *dkim.Domain
|
domain := *dkim.Domain
|
||||||
orgDomain := h.getOrganizationalDomain(domain)
|
orgDomain := getOrganizationalDomain(domain)
|
||||||
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
OrgDomain: orgDomain,
|
OrgDomain: orgDomain,
|
||||||
|
|
@ -542,7 +542,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
||||||
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
||||||
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
||||||
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
||||||
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
func getOrganizationalDomain(domain string) string {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
|
||||||
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
||||||
|
|
|
||||||
|
|
@ -291,34 +291,38 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Listed on 1 RBL",
|
name: "Listed on 1 RBL",
|
||||||
results: &DNSListResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 1,
|
ListedCount: 1,
|
||||||
|
RelevantListedCount: 1,
|
||||||
},
|
},
|
||||||
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
|
expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 2 RBLs",
|
name: "Listed on 2 RBLs",
|
||||||
results: &DNSListResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 2,
|
ListedCount: 2,
|
||||||
|
RelevantListedCount: 2,
|
||||||
},
|
},
|
||||||
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
|
expectedScore: 84, // 100 - 2*100/12 = 84
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 3 RBLs",
|
name: "Listed on 3 RBLs",
|
||||||
results: &DNSListResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 3,
|
ListedCount: 3,
|
||||||
|
RelevantListedCount: 3,
|
||||||
},
|
},
|
||||||
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
|
expectedScore: 75, // 100 - 3*100/12 = 75
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 4+ RBLs",
|
name: "Listed on 4+ RBLs",
|
||||||
results: &DNSListResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 4,
|
ListedCount: 4,
|
||||||
|
RelevantListedCount: 4,
|
||||||
},
|
},
|
||||||
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
|
expectedScore: 67, // 100 - 4*100/12 = 67
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +330,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
score, _ := checker.CalculateScore(tt.results)
|
score, _ := checker.CalculateScore(tt.results, false)
|
||||||
if score != tt.expectedScore {
|
if score != tt.expectedScore {
|
||||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dmarcRecord?: DmarcRecord;
|
dmarcRecord?: DmarcRecord;
|
||||||
|
fromDomain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord }: Props = $props();
|
let { dmarcRecord, fromDomain }: Props = $props();
|
||||||
|
|
||||||
|
const isFallback = $derived(
|
||||||
|
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
|
||||||
|
);
|
||||||
|
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 => {
|
||||||
|
|
@ -52,6 +58,24 @@
|
||||||
{/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> per RFC 9091.
|
||||||
|
{:else}
|
||||||
|
the organizational domain <code>{dmarcRecord.domain}</code> per RFC 7489.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Policy -->
|
<!-- Policy -->
|
||||||
{#if dmarcRecord.policy}
|
{#if dmarcRecord.policy}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,10 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- DMARC Record -->
|
<!-- DMARC Record -->
|
||||||
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
<DmarcRecordDisplay
|
||||||
|
dmarcRecord={dnsResults.dmarc_record}
|
||||||
|
fromDomain={dnsResults.from_domain}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- BIMI Record -->
|
<!-- BIMI Record -->
|
||||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue