Implement ARC header check
This commit is contained in:
parent
6097eb54c6
commit
8313fd7d98
8 changed files with 325 additions and 75 deletions
|
|
@ -59,6 +59,14 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
|||
}
|
||||
}
|
||||
|
||||
// Parse ARC headers if not already parsed from Authentication-Results
|
||||
if results.Arc == nil {
|
||||
results.Arc = a.parseARCHeaders(email)
|
||||
} else {
|
||||
// Enhance the ARC result with chain information from raw headers
|
||||
a.enhanceARCResult(email, results.Arc)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +119,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
|||
results.Bimi = a.parseBIMIResult(part)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ARC
|
||||
if strings.HasPrefix(part, "arc=") {
|
||||
if results.Arc == nil {
|
||||
results.Arc = a.parseARCResult(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +274,163 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
|||
return result
|
||||
}
|
||||
|
||||
// parseARCResult parses ARC result from Authentication-Results
|
||||
// Example: arc=pass
|
||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
||||
result := &api.ARCResult{}
|
||||
|
||||
// Extract result (pass, fail, none)
|
||||
re := regexp.MustCompile(`arc=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
resultStr := strings.ToLower(matches[1])
|
||||
result.Result = api.ARCResultResult(resultStr)
|
||||
}
|
||||
|
||||
// Extract details
|
||||
if idx := strings.Index(part, "("); idx != -1 {
|
||||
endIdx := strings.Index(part[idx:], ")")
|
||||
if endIdx != -1 {
|
||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||
result.Details = &details
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Get all ARC-related headers
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
|
||||
// If no ARC headers present, return nil
|
||||
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.ARCResult{
|
||||
Result: api.ARCResultResultNone,
|
||||
}
|
||||
|
||||
// Count the ARC chain length (number of sets)
|
||||
chainLength := len(arcSeal)
|
||||
result.ChainLength = &chainLength
|
||||
|
||||
// Validate the ARC chain
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
result.ChainValid = &chainValid
|
||||
|
||||
// Determine overall result
|
||||
if chainLength == 0 {
|
||||
result.Result = api.ARCResultResultNone
|
||||
details := "No ARC chain present"
|
||||
result.Details = &details
|
||||
} else if !chainValid {
|
||||
result.Result = api.ARCResultResultFail
|
||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||
result.Details = &details
|
||||
} else {
|
||||
result.Result = api.ARCResultResultPass
|
||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||
result.Details = &details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// enhanceARCResult enhances an existing ARC result with chain information
|
||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
||||
if arcResult == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get ARC headers
|
||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||
|
||||
// Set chain length if not already set
|
||||
if arcResult.ChainLength == nil {
|
||||
chainLength := len(arcSeal)
|
||||
arcResult.ChainLength = &chainLength
|
||||
}
|
||||
|
||||
// Validate chain if not already validated
|
||||
if arcResult.ChainValid == nil {
|
||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||
arcResult.ChainValid = &chainValid
|
||||
}
|
||||
}
|
||||
|
||||
// validateARCChain validates the ARC chain for completeness
|
||||
// Each instance should have all three headers with matching instance numbers
|
||||
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
|
||||
// All three header types should have the same count
|
||||
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(arcSeal) == 0 {
|
||||
return true // No ARC chain is technically valid
|
||||
}
|
||||
|
||||
// Extract instance numbers from each header type
|
||||
sealInstances := a.extractARCInstances(arcSeal)
|
||||
sigInstances := a.extractARCInstances(arcMessageSig)
|
||||
authInstances := a.extractARCInstances(arcAuthResults)
|
||||
|
||||
// Check that all instance numbers match and are sequential starting from 1
|
||||
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify instances are sequential from 1 to N
|
||||
for i := 1; i <= len(sealInstances); i++ {
|
||||
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// extractARCInstances extracts instance numbers from ARC headers
|
||||
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
|
||||
var instances []int
|
||||
re := regexp.MustCompile(`i=(\d+)`)
|
||||
|
||||
for _, header := range headers {
|
||||
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
|
||||
var instance int
|
||||
fmt.Sscanf(matches[1], "%d", &instance)
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// contains checks if a slice contains an integer
|
||||
func contains(slice []int, val int) bool {
|
||||
for _, item := range slice {
|
||||
if item == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pluralize returns "y" or "ies" based on count
|
||||
func pluralize(count int) string {
|
||||
if count == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||
receivedSPF := email.Header.Get("Received-SPF")
|
||||
|
|
@ -389,7 +561,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
|
|||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No SPF authentication result found",
|
||||
Severity: api.PtrTo(api.Medium),
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
|
||||
})
|
||||
}
|
||||
|
|
@ -407,7 +579,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
|
|||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No DKIM signature found",
|
||||
Severity: api.PtrTo(api.Medium),
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
||||
})
|
||||
}
|
||||
|
|
@ -423,7 +595,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
|
|||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Message: "No DMARC authentication result found",
|
||||
Severity: api.PtrTo(api.Medium),
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||
})
|
||||
}
|
||||
|
|
@ -434,6 +606,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
|
|||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// ARC check (optional, for forwarded emails)
|
||||
if results.Arc != nil {
|
||||
check := a.generateARCCheck(results.Arc)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
|
|
@ -448,31 +626,31 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check
|
|||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "SPF validation passed"
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "SPF validation failed"
|
||||
check.Severity = api.PtrTo(api.Critical)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
||||
case api.AuthResultResultSoftfail:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.5
|
||||
check.Message = "SPF validation softfail"
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||
case api.AuthResultResultNeutral:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.5
|
||||
check.Message = "SPF validation neutral"
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||
}
|
||||
|
||||
|
|
@ -495,19 +673,19 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i
|
|||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "DKIM signature is valid"
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "DKIM signature validation failed"
|
||||
check.Severity = api.PtrTo(api.High)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
||||
}
|
||||
|
||||
|
|
@ -537,19 +715,19 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C
|
|||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Message = "DMARC validation passed"
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0.0
|
||||
check.Message = "DMARC validation failed"
|
||||
check.Severity = api.PtrTo(api.High)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
||||
default:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
||||
check.Severity = api.PtrTo(api.Medium)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
||||
}
|
||||
|
||||
|
|
@ -572,19 +750,19 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che
|
|||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
|
||||
check.Message = "BIMI validation passed"
|
||||
check.Severity = api.PtrTo(api.Info)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = "BIMI validation failed"
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
|
||||
default:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
|
||||
check.Severity = api.PtrTo(api.Low)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
|
||||
}
|
||||
|
||||
|
|
@ -595,3 +773,50 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che
|
|||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "ARC (Authenticated Received Chain)",
|
||||
}
|
||||
|
||||
switch arc.Result {
|
||||
case api.ARCResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
|
||||
check.Message = "ARC chain validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
|
||||
case api.ARCResultResultFail:
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Message = "ARC chain validation failed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
|
||||
default:
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0.0
|
||||
check.Message = "No ARC chain present"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
|
||||
}
|
||||
|
||||
// Build details
|
||||
var detailsParts []string
|
||||
if arc.ChainLength != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
|
||||
}
|
||||
if arc.ChainValid != nil {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
|
||||
}
|
||||
if arc.Details != nil {
|
||||
detailsParts = append(detailsParts, *arc.Details)
|
||||
}
|
||||
|
||||
if len(detailsParts) > 0 {
|
||||
details := strings.Join(detailsParts, ", ")
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue