diff --git a/api/openapi.yaml b/api/openapi.yaml index 139a512..a178dc9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -680,6 +680,9 @@ components: $ref: '#/components/schemas/ARCResult' iprev: $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) AuthResult: type: object diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index e89cb77..2003c48 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -134,6 +134,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Iprev = a.parseIPRevResult(part) } } + + // Parse x-google-dkim + if strings.HasPrefix(part, "x-google-dkim=") { + if results.XGoogleDkim == nil { + results.XGoogleDkim = a.parseXGoogleDKIMResult(part) + } + } } } @@ -299,6 +306,37 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult return result } +// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results +// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`x-google-dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) - though not always present in x-google-dkim + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + + 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 { @@ -549,6 +587,16 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe } } + // X-Google-DKIM (optional) - penalty if failed + if results.XGoogleDkim != nil { + switch results.XGoogleDkim.Result { + case api.AuthResultResultPass: + // pass: don't alter the score + default: // fail + score -= 12 + } + } + // DMARC (25 points) if results.Dmarc != nil { switch results.Dmarc.Result { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 554d423..f0b7163 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -1261,6 +1261,61 @@ func TestParseIPRevResult(t *testing.T) { } } +func TestParseXGoogleDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + expectedSelector string + }{ + { + name: "x-google-dkim pass with domain", + part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", + expectedResult: api.AuthResultResultPass, + expectedDomain: "1e100.net", + }, + { + name: "x-google-dkim pass with short form", + part: "x-google-dkim=pass d=gmail.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "gmail.com", + }, + { + name: "x-google-dkim fail", + part: "x-google-dkim=fail header.d=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "x-google-dkim with minimal info", + part: "x-google-dkim=pass", + expectedResult: api.AuthResultResultPass, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseXGoogleDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if tt.expectedDomain != "" { + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + } + }) + } +} + func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 344495c..d30ff39 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -183,6 +183,36 @@ + + {#if authentication.x_google_dkim} +
{authentication.x_google_dkim.details}
+ {/if}
+