Add iprev check
This commit is contained in:
parent
3d03bfc4fa
commit
c1063cb4aa
4 changed files with 307 additions and 7 deletions
|
|
@ -678,6 +678,8 @@ components:
|
|||
$ref: '#/components/schemas/AuthResult'
|
||||
arc:
|
||||
$ref: '#/components/schemas/ARCResult'
|
||||
iprev:
|
||||
$ref: '#/components/schemas/IPRevResult'
|
||||
|
||||
AuthResult:
|
||||
type: object
|
||||
|
|
@ -724,6 +726,29 @@ components:
|
|||
description: Additional details about ARC validation
|
||||
example: "ARC chain valid with 2 intermediaries"
|
||||
|
||||
IPRevResult:
|
||||
type: object
|
||||
required:
|
||||
- result
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
enum: [pass, fail, temperror, permerror]
|
||||
description: IP reverse DNS lookup result
|
||||
example: "pass"
|
||||
ip:
|
||||
type: string
|
||||
description: IP address that was checked
|
||||
example: "195.110.101.58"
|
||||
hostname:
|
||||
type: string
|
||||
description: Hostname from reverse DNS lookup (PTR record)
|
||||
example: "authsmtp74.register.it"
|
||||
details:
|
||||
type: string
|
||||
description: Additional details about the IP reverse lookup
|
||||
example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)"
|
||||
|
||||
SpamAssassinResult:
|
||||
type: object
|
||||
required:
|
||||
|
|
|
|||
|
|
@ -127,6 +127,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
|||
results.Arc = a.parseARCResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse IPRev
|
||||
if strings.HasPrefix(part, "iprev=") {
|
||||
if results.Iprev == nil {
|
||||
results.Iprev = a.parseIPRevResult(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +268,37 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
|||
return result
|
||||
}
|
||||
|
||||
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
||||
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
||||
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
|
||||
result := &api.IPRevResult{}
|
||||
|
||||
// Extract result (pass, fail, temperror, permerror, none)
|
||||
re := regexp.MustCompile(`iprev=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.IPRevResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract IP address (smtp.remote-ip or remote-ip)
|
||||
ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`)
|
||||
if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
ip := matches[1]
|
||||
result.Ip = &ip
|
||||
}
|
||||
|
||||
// Extract hostname from parentheses
|
||||
hostnameRe := regexp.MustCompile(`\(([^)]+)\)`)
|
||||
if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||
hostname := matches[1]
|
||||
result.Hostname = &hostname
|
||||
}
|
||||
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseARCHeaders parses ARC headers from email message
|
||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
||||
|
|
@ -470,21 +508,31 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
|||
|
||||
score := 0
|
||||
|
||||
// SPF (30 points)
|
||||
if results.Spf != nil {
|
||||
switch results.Spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 30
|
||||
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
||||
// IPRev (15 points)
|
||||
if results.Iprev != nil {
|
||||
switch results.Iprev.Result {
|
||||
case api.Pass:
|
||||
score += 15
|
||||
case api.AuthResultResultSoftfail:
|
||||
score += 5
|
||||
default: // fail, temperror, permerror
|
||||
score += 0
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM (30 points) - at least one passing signature
|
||||
// SPF (25 points)
|
||||
if results.Spf != nil {
|
||||
switch results.Spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 25
|
||||
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
||||
score += 12
|
||||
case api.AuthResultResultSoftfail:
|
||||
score += 4
|
||||
default: // fail, temperror, permerror
|
||||
score += 0
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM (20 points) - at least one passing signature
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
hasPass := false
|
||||
for _, dkim := range *results.Dkim {
|
||||
|
|
@ -494,10 +542,10 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
|||
}
|
||||
}
|
||||
if hasPass {
|
||||
score += 30
|
||||
score += 20
|
||||
} else {
|
||||
// Has DKIM signatures but none passed
|
||||
score += 10
|
||||
score += 7
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1149,3 +1149,200 @@ func TestParseLegacyDKIM_Integration(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseIPRevResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part string
|
||||
expectedResult api.IPRevResultResult
|
||||
expectedIP *string
|
||||
expectedHostname *string
|
||||
}{
|
||||
{
|
||||
name: "IPRev pass with IP and hostname",
|
||||
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("195.110.101.58"),
|
||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||
},
|
||||
{
|
||||
name: "IPRev pass without smtp prefix",
|
||||
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("mail.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev fail",
|
||||
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
||||
expectedResult: api.Fail,
|
||||
expectedIP: api.PtrTo("198.51.100.42"),
|
||||
expectedHostname: api.PtrTo("unknown.host.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev temperror",
|
||||
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
||||
expectedResult: api.Temperror,
|
||||
expectedIP: api.PtrTo("203.0.113.1"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "IPRev permerror",
|
||||
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
||||
expectedResult: api.Permerror,
|
||||
expectedIP: api.PtrTo("192.0.2.100"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "IPRev with IPv6",
|
||||
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("2001:db8::1"),
|
||||
expectedHostname: api.PtrTo("ipv6.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev with subdomain hostname",
|
||||
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.50"),
|
||||
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev pass without parentheses",
|
||||
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
||||
expectedResult: api.Pass,
|
||||
expectedIP: api.PtrTo("192.0.2.200"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.parseIPRevResult(tt.part)
|
||||
|
||||
// Check result
|
||||
if result.Result != tt.expectedResult {
|
||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||
}
|
||||
|
||||
// Check IP
|
||||
if tt.expectedIP != nil {
|
||||
if result.Ip == nil {
|
||||
t.Errorf("IP = nil, want %v", *tt.expectedIP)
|
||||
} else if *result.Ip != *tt.expectedIP {
|
||||
t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP)
|
||||
}
|
||||
} else {
|
||||
if result.Ip != nil {
|
||||
t.Errorf("IP = %v, want nil", *result.Ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Check hostname
|
||||
if tt.expectedHostname != nil {
|
||||
if result.Hostname == nil {
|
||||
t.Errorf("Hostname = nil, want %v", *tt.expectedHostname)
|
||||
} else if *result.Hostname != *tt.expectedHostname {
|
||||
t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname)
|
||||
}
|
||||
} else {
|
||||
if result.Hostname != nil {
|
||||
t.Errorf("Hostname = %v, want nil", *result.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
// Check details
|
||||
if result.Details == nil {
|
||||
t.Error("Expected Details to be set, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedIPRevResult *api.IPRevResultResult
|
||||
expectedIP *string
|
||||
expectedHostname *string
|
||||
}{
|
||||
{
|
||||
name: "IPRev pass in Authentication-Results",
|
||||
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("195.110.101.58"),
|
||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
||||
},
|
||||
{
|
||||
name: "IPRev with other authentication methods",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("mail.example.com"),
|
||||
},
|
||||
{
|
||||
name: "IPRev fail",
|
||||
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
||||
expectedIPRevResult: api.PtrTo(api.Fail),
|
||||
expectedIP: api.PtrTo("198.51.100.42"),
|
||||
expectedHostname: nil,
|
||||
},
|
||||
{
|
||||
name: "No IPRev in header",
|
||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com",
|
||||
expectedIPRevResult: nil,
|
||||
},
|
||||
{
|
||||
name: "Multiple IPRev results - only first is parsed",
|
||||
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
||||
expectedIP: api.PtrTo("192.0.2.1"),
|
||||
expectedHostname: api.PtrTo("first.com"),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := &api.AuthenticationResults{}
|
||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||
|
||||
// Check IPRev
|
||||
if tt.expectedIPRevResult != nil {
|
||||
if results.Iprev == nil {
|
||||
t.Errorf("Expected IPRev result, got nil")
|
||||
} else {
|
||||
if results.Iprev.Result != *tt.expectedIPRevResult {
|
||||
t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult)
|
||||
}
|
||||
if tt.expectedIP != nil {
|
||||
if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP {
|
||||
var gotIP string
|
||||
if results.Iprev.Ip != nil {
|
||||
gotIP = *results.Iprev.Ip
|
||||
}
|
||||
t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP)
|
||||
}
|
||||
}
|
||||
if tt.expectedHostname != nil {
|
||||
if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname {
|
||||
var gotHostname string
|
||||
if results.Iprev.Hostname != nil {
|
||||
gotHostname = *results.Iprev.Hostname
|
||||
}
|
||||
t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if results.Iprev != nil {
|
||||
t.Errorf("Expected no IPRev result, got %+v", results.Iprev)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,36 @@
|
|||
</h4>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.iprev.result)} {getAuthResultClass(authentication.iprev.result)} me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>IP Reverse DNS</strong>
|
||||
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.iprev.result)}">
|
||||
{authentication.iprev.result}
|
||||
</span>
|
||||
{#if authentication.iprev.ip}
|
||||
<div class="small">
|
||||
<strong>IP Address:</strong>
|
||||
<span class="text-muted">{authentication.iprev.ip}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.hostname}
|
||||
<div class="small">
|
||||
<strong>Hostname:</strong>
|
||||
<span class="text-muted">{authentication.iprev.hostname}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authentication.iprev.details}
|
||||
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.iprev.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SPF (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-start">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue