Add x-align-form authentication test

This commit is contained in:
nemunaire 2025-10-24 15:21:54 +07:00
commit 52f43c6bc5
8 changed files with 260 additions and 8 deletions

View file

@ -683,6 +683,9 @@ components:
x_google_dkim:
$ref: '#/components/schemas/AuthResult'
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:
type: object

View file

@ -138,6 +138,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
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)
score += 25 * a.calculateSPFScore(results) / 100
// DKIM (25 points)
score += 25 * a.calculateDKIMScore(results) / 100
// DKIM (23 points)
score += 23 * a.calculateDKIMScore(results) / 100
// X-Google-DKIM (optional) - penalty if failed
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// X-Aligned-From
score += 2 * a.calculateXAlignedFromScore(results) / 100
// DMARC (25 points)
score += 25 * a.calculateDMARCScore(results) / 100

View file

@ -46,7 +46,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: api.AuthResultResultPass,
},
},
expectedScore: 75, // SPF=25 + DKIM=25 + DMARC=25
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
},
{
name: "SPF and DKIM only",
@ -58,7 +58,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: api.AuthResultResultPass},
},
},
expectedScore: 50, // SPF=25 + DKIM=25
expectedScore: 48, // SPF=25 + DKIM=23
},
{
name: "SPF fail, DKIM pass",
@ -70,7 +70,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: api.AuthResultResultPass},
},
},
expectedScore: 25, // SPF=0 + DKIM=25
expectedScore: 23, // SPF=0 + DKIM=23
},
{
name: "SPF softfail",

View 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
}

View 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)
}
})
}
}

View file

@ -213,6 +213,30 @@
</div>
{/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) -->
<div class="list-group-item" id="authentication-dmarc">
<div class="d-flex align-items-start">

View file

@ -1,5 +1,5 @@
<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 GradeDisplay from "./GradeDisplay.svelte";
@ -8,9 +8,10 @@
headerAnalysis: HeaderAnalysis;
headerGrade?: string;
headerScore?: number;
xAlignedFrom?: AuthResult;
}
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
</script>
<div class="card shadow-sm" id="header-details">
@ -60,7 +61,11 @@
<div class="card mb-3" id="domain-alignment">
<div class="card-header">
<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
</h5>
</div>

View file

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