Compare commits

..

No commits in common. "1c4eb0653ed7e72ab6404e98cb35fbd268ebf2c2" and "3b301a415fa91f250adf4c8a94ea000679be6f66" have entirely different histories.

6 changed files with 26 additions and 106 deletions

View file

@ -760,7 +760,7 @@ components:
properties: properties:
result: result:
type: string type: string
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass] enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined]
description: Authentication result description: Authentication result
example: "pass" example: "pass"
domain: domain:

View file

@ -33,12 +33,11 @@ import (
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives // checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
visited := make(map[string]bool) visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0, true) return d.resolveSPFRecords(domain, visited, 0)
} }
// resolveSPFRecords recursively resolves SPF records including include: directives // resolveSPFRecords recursively resolves SPF records including include: directives
// isMainRecord indicates if this is the primary domain's record (not an included one) func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord {
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth { if depth > maxDepth {
@ -104,7 +103,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
} }
// Basic validation // Basic validation
validationErr := d.validateSPF(spfRecord, isMainRecord) validationErr := d.validateSPF(spfRecord)
// Extract the "all" mechanism qualifier // Extract the "all" mechanism qualifier
var allQualifier *api.SPFRecordAllQualifier var allQualifier *api.SPFRecordAllQualifier
@ -141,7 +140,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
if redirectDomain != "" { if redirectDomain != "" {
// redirect= replaces the current domain's policy entirely // redirect= replaces the current domain's policy entirely
// Only follow if no other mechanisms matched (per RFC 7208) // Only follow if no other mechanisms matched (per RFC 7208)
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false) redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1)
if redirectRecords != nil { if redirectRecords != nil {
results = append(results, *redirectRecords...) results = append(results, *redirectRecords...)
} }
@ -151,7 +150,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
// Extract and resolve include: directives // Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord) includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes { for _, includeDomain := range includes {
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false) includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1)
if includedRecords != nil { if includedRecords != nil {
results = append(results, *includedRecords...) results = append(results, *includedRecords...)
} }
@ -237,8 +236,7 @@ func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
} }
// validateSPF performs basic SPF record validation // validateSPF performs basic SPF record validation
// isMainRecord indicates if this is the primary domain's record (not an included one) func (d *DNSAnalyzer) validateSPF(record string) error {
func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error {
// Must start with v=spf1 // Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") { if !strings.HasPrefix(record, "v=spf1") {
return fmt.Errorf("SPF record must start with 'v=spf1'") return fmt.Errorf("SPF record must start with 'v=spf1'")
@ -271,22 +269,19 @@ func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error {
return nil return nil
} }
// Only check for 'all' mechanism on the main record, not on included records // Check for common syntax issues
if isMainRecord { // Should have a final mechanism (all, +all, -all, ~all, ?all)
// Check for common syntax issues validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
// Should have a final mechanism (all, +all, -all, ~all, ?all) hasValidEnding := false
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} for _, ending := range validEndings {
hasValidEnding := false if strings.HasSuffix(record, ending) {
for _, ending := range validEndings { hasValidEnding = true
if strings.HasSuffix(record, ending) { break
hasValidEnding = true
break
}
} }
}
if !hasValidEnding { if !hasValidEnding {
return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier") return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier")
}
} }
return nil return nil

View file

@ -128,8 +128,7 @@ func TestValidateSPF(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) {
// Test as main record (isMainRecord = true) since these tests check overall SPF validity err := analyzer.validateSPF(tt.record)
err := analyzer.validateSPF(tt.record, true)
if tt.expectError { if tt.expectError {
if err == nil { if err == nil {
t.Errorf("validateSPF(%q) expected error but got nil", tt.record) t.Errorf("validateSPF(%q) expected error but got nil", tt.record)
@ -145,74 +144,6 @@ func TestValidateSPF(t *testing.T) {
} }
} }
func TestValidateSPF_IncludedRecords(t *testing.T) {
tests := []struct {
name string
record string
isMainRecord bool
expectError bool
errorMsg string
}{
{
name: "Main record without 'all' - should error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record without 'all' - should NOT error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: false,
expectError: false,
},
{
name: "Included record with only mechanisms - should NOT error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with only mechanisms - should error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: true,
expectError: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := analyzer.validateSPF(tt.record, tt.isMainRecord)
if tt.expectError {
if err == nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err)
}
}
})
}
}
func TestExtractSPFRedirect(t *testing.T) { func TestExtractSPFRedirect(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -16,16 +16,10 @@
function getAuthResultClass(result: string, noneIsFail: boolean): string { function getAuthResultClass(result: string, noneIsFail: boolean): string {
switch (result) { switch (result) {
case "pass": case "pass":
case "domain_pass":
case "orgdomain_pass":
return "text-success"; return "text-success";
case "error":
case "fail": case "fail":
case "missing": case "missing":
case "invalid": case "invalid":
case "null":
case "null_smtp":
case "null_header":
return "text-danger"; return "text-danger";
case "softfail": case "softfail":
case "neutral": case "neutral":
@ -42,18 +36,12 @@
function getAuthResultIcon(result: string, noneIsFail: boolean): string { function getAuthResultIcon(result: string, noneIsFail: boolean): string {
switch (result) { switch (result) {
case "pass": case "pass":
case "domain_pass":
case "orgdomain_pass":
return "bi-check-circle-fill"; return "bi-check-circle-fill";
case "fail": case "fail":
return "bi-x-circle-fill"; return "bi-x-circle-fill";
case "softfail": case "softfail":
case "neutral": case "neutral":
case "invalid": case "invalid":
case "null":
case "error":
case "null_smtp":
case "null_header":
return "bi-exclamation-circle-fill"; return "bi-exclamation-circle-fill";
case "missing": case "missing":
return "bi-dash-circle-fill"; return "bi-dash-circle-fill";

View file

@ -9,6 +9,7 @@
headerAnalysis: HeaderAnalysis; headerAnalysis: HeaderAnalysis;
headerGrade?: string; headerGrade?: string;
headerScore?: number; headerScore?: number;
xAlignedFrom?: AuthResult;
} }
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props(); let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
@ -61,7 +62,11 @@
<div class="card mb-3" id="domain-alignment"> <div class="card mb-3" id="domain-alignment">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i> {#if xAlignedFrom}
<i class="bi {xAlignedFrom.result == "pass" ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger'}"></i>
{:else}
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill text-success' : headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle text-info' : 'bi-x-circle-fill text-danger'}"></i>
{/if}
Domain Alignment Domain Alignment
</h5> </h5>
</div> </div>

View file

@ -335,6 +335,7 @@
headerAnalysis={report.header_analysis} headerAnalysis={report.header_analysis}
headerGrade={report.summary?.header_grade} headerGrade={report.summary?.header_grade}
headerScore={report.summary?.header_score} headerScore={report.summary?.header_score}
xAlignedFrom={report.authentication?.x_aligned_from}
/> />
</div> </div>
</div> </div>