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:
|
||||
$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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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>
|
||||
{/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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue