Add x-align-form authentication test
This commit is contained in:
parent
3ea958b2fd
commit
52f43c6bc5
8 changed files with 260 additions and 8 deletions
|
|
@ -683,6 +683,9 @@ components:
|
||||||
x_google_dkim:
|
x_google_dkim:
|
||||||
$ref: '#/components/schemas/AuthResult'
|
$ref: '#/components/schemas/AuthResult'
|
||||||
description: Google-specific DKIM authentication result (x-google-dkim)
|
description: Google-specific DKIM authentication result (x-google-dkim)
|
||||||
|
x_aligned_from:
|
||||||
|
$ref: '#/components/schemas/AuthResult'
|
||||||
|
description: X-Aligned-From authentication result (checks address alignment)
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
|
results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse x-aligned-from
|
||||||
|
if strings.HasPrefix(part, "x-aligned-from=") {
|
||||||
|
if results.XAlignedFrom == nil {
|
||||||
|
results.XAlignedFrom = a.parseXAlignedFromResult(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,12 +163,15 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
||||||
// SPF (25 points)
|
// SPF (25 points)
|
||||||
score += 25 * a.calculateSPFScore(results) / 100
|
score += 25 * a.calculateSPFScore(results) / 100
|
||||||
|
|
||||||
// DKIM (25 points)
|
// DKIM (23 points)
|
||||||
score += 25 * a.calculateDKIMScore(results) / 100
|
score += 23 * a.calculateDKIMScore(results) / 100
|
||||||
|
|
||||||
// X-Google-DKIM (optional) - penalty if failed
|
// X-Google-DKIM (optional) - penalty if failed
|
||||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||||
|
|
||||||
|
// X-Aligned-From
|
||||||
|
score += 2 * a.calculateXAlignedFromScore(results) / 100
|
||||||
|
|
||||||
// DMARC (25 points)
|
// DMARC (25 points)
|
||||||
score += 25 * a.calculateDMARCScore(results) / 100
|
score += 25 * a.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
||||||
Result: api.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 75, // SPF=25 + DKIM=25 + DMARC=25
|
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF and DKIM only",
|
name: "SPF and DKIM only",
|
||||||
|
|
@ -58,7 +58,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
||||||
{Result: api.AuthResultResultPass},
|
{Result: api.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 50, // SPF=25 + DKIM=25
|
expectedScore: 48, // SPF=25 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail, DKIM pass",
|
name: "SPF fail, DKIM pass",
|
||||||
|
|
@ -70,7 +70,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
||||||
{Result: api.AuthResultResultPass},
|
{Result: api.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 25, // SPF=0 + DKIM=25
|
expectedScore: 23, // SPF=0 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
|
|
|
||||||
65
pkg/analyzer/authentication_x_aligned_from.go
Normal file
65
pkg/analyzer/authentication_x_aligned_from.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
||||||
|
// Example: x-aligned-from=pass (Address match)
|
||||||
|
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
|
||||||
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
|
// Extract result (pass, fail, etc.)
|
||||||
|
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
||||||
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
resultStr := strings.ToLower(matches[1])
|
||||||
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract details (everything after the result)
|
||||||
|
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
|
||||||
|
if results.XAlignedFrom != nil {
|
||||||
|
switch results.XAlignedFrom.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
// pass: positive contribution
|
||||||
|
return 100
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
// fail: negative contribution
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
// neutral, none, etc.: no impact
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
144
pkg/analyzer/authentication_x_aligned_from_test.go
Normal file
144
pkg/analyzer/authentication_x_aligned_from_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.AuthResultResult
|
||||||
|
expectedDetail string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "x-aligned-from pass with details",
|
||||||
|
part: "x-aligned-from=pass (Address match)",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDetail: "pass (Address match)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x-aligned-from fail with reason",
|
||||||
|
part: "x-aligned-from=fail (Address mismatch)",
|
||||||
|
expectedResult: api.AuthResultResultFail,
|
||||||
|
expectedDetail: "fail (Address mismatch)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x-aligned-from pass minimal",
|
||||||
|
part: "x-aligned-from=pass",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDetail: "pass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x-aligned-from neutral",
|
||||||
|
part: "x-aligned-from=neutral (No alignment check performed)",
|
||||||
|
expectedResult: api.AuthResultResultNeutral,
|
||||||
|
expectedDetail: "neutral (No alignment check performed)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x-aligned-from none",
|
||||||
|
part: "x-aligned-from=none",
|
||||||
|
expectedResult: api.AuthResultResultNone,
|
||||||
|
expectedDetail: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseXAlignedFromResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Details == nil {
|
||||||
|
t.Errorf("Details = nil, want %v", tt.expectedDetail)
|
||||||
|
} else if *result.Details != tt.expectedDetail {
|
||||||
|
t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *api.AuthResult
|
||||||
|
expectedScore int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pass result gives positive score",
|
||||||
|
result: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
expectedScore: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail result gives zero score",
|
||||||
|
result: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
},
|
||||||
|
expectedScore: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neutral result gives zero score",
|
||||||
|
result: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultNeutral,
|
||||||
|
},
|
||||||
|
expectedScore: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none result gives zero score",
|
||||||
|
result: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultNone,
|
||||||
|
},
|
||||||
|
expectedScore: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil result gives zero score",
|
||||||
|
result: nil,
|
||||||
|
expectedScore: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
results := &api.AuthenticationResults{
|
||||||
|
XAlignedFrom: tt.result,
|
||||||
|
}
|
||||||
|
|
||||||
|
score := analyzer.calculateXAlignedFromScore(results)
|
||||||
|
|
||||||
|
if score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -213,6 +213,30 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- X-Aligned-From (Disabled) -->
|
||||||
|
{#if false && authentication.x_aligned_from}
|
||||||
|
<div class="list-group-item" id="authentication-x-aligned-from">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi {getAuthResultIcon(authentication.x_aligned_from.result, false)} {getAuthResultClass(authentication.x_aligned_from.result, false)} me-2 fs-5"></i>
|
||||||
|
<div>
|
||||||
|
<strong>X-Aligned-From</strong>
|
||||||
|
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.x_aligned_from.result, false)}">
|
||||||
|
{authentication.x_aligned_from.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.x_aligned_from.domain}
|
||||||
|
<div class="small">
|
||||||
|
<strong>Domain:</strong>
|
||||||
|
<span class="text-muted">{authentication.x_aligned_from.domain}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authentication.x_aligned_from.details}
|
||||||
|
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.x_aligned_from.details}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- DMARC (Required) -->
|
<!-- DMARC (Required) -->
|
||||||
<div class="list-group-item" id="authentication-dmarc">
|
<div class="list-group-item" id="authentication-dmarc">
|
||||||
<div class="d-flex align-items-start">
|
<div class="d-flex align-items-start">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DMARCRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
import type { AuthResult, DMARCRecord, HeaderAnalysis } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
headerAnalysis: HeaderAnalysis;
|
headerAnalysis: HeaderAnalysis;
|
||||||
headerGrade?: string;
|
headerGrade?: string;
|
||||||
headerScore?: number;
|
headerScore?: number;
|
||||||
|
xAlignedFrom?: AuthResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
|
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="header-details">
|
<div class="card shadow-sm" id="header-details">
|
||||||
|
|
@ -60,7 +61,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 == "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>
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,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