Compare commits
No commits in common. "1c4eb0653ed7e72ab6404e98cb35fbd268ebf2c2" and "3b301a415fa91f250adf4c8a94ea000679be6f66" have entirely different histories.
1c4eb0653e
...
3b301a415f
6 changed files with 26 additions and 106 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue