diff --git a/api/openapi.yaml b/api/openapi.yaml index 88532b3..5484f9e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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: diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index ef0c400..84bbb9e 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -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 } } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index d0c4f18..8535ff2 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -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) + } + } + }) + } +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index b44c102..b1a7864 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -73,6 +73,36 @@
{authentication.iprev.details}
+ {/if}
+