diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index e75b3ac..c76359c 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -22,11 +22,7 @@ package analyzer import ( - "context" - "fmt" "net" - "regexp" - "strings" "time" "git.happydns.org/happyDeliver/internal/api" @@ -128,570 +124,6 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic return results } -// checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - mxRecords, err := d.resolver.LookupMX(ctx, domain) - if err != nil { - return &[]api.MXRecord{ - { - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), - }, - } - } - - if len(mxRecords) == 0 { - return &[]api.MXRecord{ - { - Valid: false, - Error: api.PtrTo("No MX records found"), - }, - } - } - - var results []api.MXRecord - for _, mx := range mxRecords { - results = append(results, api.MXRecord{ - Host: mx.Host, - Priority: mx.Pref, - Valid: true, - }) - } - - return &results -} - -// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives -func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { - visited := make(map[string]bool) - return d.resolveSPFRecords(domain, visited, 0) -} - -// resolveSPFRecords recursively resolves SPF records including include: directives -func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { - const maxDepth = 10 // Prevent infinite recursion - - if depth > maxDepth { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo("Maximum SPF include depth exceeded"), - }, - } - } - - // Prevent circular references - if visited[domain] { - return &[]api.SPFRecord{} - } - visited[domain] = true - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, domain) - if err != nil { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), - }, - } - } - - // Find SPF record (starts with "v=spf1") - var spfRecord string - spfCount := 0 - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=spf1") { - spfRecord = txt - spfCount++ - } - } - - if spfCount == 0 { - return &[]api.SPFRecord{ - { - Domain: &domain, - Valid: false, - Error: api.PtrTo("No SPF record found"), - }, - } - } - - var results []api.SPFRecord - - if spfCount > 1 { - results = append(results, api.SPFRecord{ - Domain: &domain, - Record: &spfRecord, - Valid: false, - Error: api.PtrTo("Multiple SPF records found (RFC violation)"), - }) - return &results - } - - // Basic validation - valid := d.validateSPF(spfRecord) - - // Extract the "all" mechanism qualifier - var allQualifier *api.SPFRecordAllQualifier - var errMsg *string - - if !valid { - errMsg = api.PtrTo("SPF record appears malformed") - } else { - // Extract qualifier from the "all" mechanism - if strings.HasSuffix(spfRecord, " -all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) - } else if strings.HasSuffix(spfRecord, " ~all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) - } else if strings.HasSuffix(spfRecord, " +all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) - } else if strings.HasSuffix(spfRecord, " ?all") { - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) - } else if strings.HasSuffix(spfRecord, " all") { - // Implicit + qualifier (default) - allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) - } - } - - results = append(results, api.SPFRecord{ - Domain: &domain, - Record: &spfRecord, - Valid: valid, - AllQualifier: allQualifier, - Error: errMsg, - }) - - // Check for redirect= modifier first (it replaces the entire SPF policy) - redirectDomain := d.extractSPFRedirect(spfRecord) - if redirectDomain != "" { - // redirect= replaces the current domain's policy entirely - // Only follow if no other mechanisms matched (per RFC 7208) - redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) - if redirectRecords != nil { - results = append(results, *redirectRecords...) - } - return &results - } - - // Extract and resolve include: directives - includes := d.extractSPFIncludes(spfRecord) - for _, includeDomain := range includes { - includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) - if includedRecords != nil { - results = append(results, *includedRecords...) - } - } - - return &results -} - -// extractSPFIncludes extracts all include: domains from an SPF record -func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { - var includes []string - re := regexp.MustCompile(`include:([^\s]+)`) - matches := re.FindAllStringSubmatch(record, -1) - for _, match := range matches { - if len(match) > 1 { - includes = append(includes, match[1]) - } - } - return includes -} - -// extractSPFRedirect extracts the redirect= domain from an SPF record -// The redirect= modifier replaces the current domain's SPF policy with that of the target domain -func (d *DNSAnalyzer) extractSPFRedirect(record string) string { - re := regexp.MustCompile(`redirect=([^\s]+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "" -} - -// validateSPF performs basic SPF record validation -func (d *DNSAnalyzer) validateSPF(record string) bool { - // Must start with v=spf1 - if !strings.HasPrefix(record, "v=spf1") { - return false - } - - // Check for redirect= modifier (which replaces the need for an 'all' mechanism) - if strings.Contains(record, "redirect=") { - return true - } - - // Check for common syntax issues - // Should have a final mechanism (all, +all, -all, ~all, ?all) - validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} - hasValidEnding := false - for _, ending := range validEndings { - if strings.HasSuffix(record, ending) { - hasValidEnding = true - break - } - } - - return hasValidEnding -} - -// hasSPFStrictFail checks if SPF record has strict -all mechanism -func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { - return strings.HasSuffix(record, " -all") -} - -// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { - // DKIM records are at: selector._domainkey.domain - dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) - if err != nil { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), - } - } - - if len(txtRecords) == 0 { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo("No DKIM record found"), - } - } - - // Concatenate all TXT record parts (DKIM can be split) - dkimRecord := strings.Join(txtRecords, "") - - // Basic validation - should contain "v=DKIM1" and "p=" (public key) - if !d.validateDKIM(dkimRecord) { - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Record: api.PtrTo(dkimRecord), - Valid: false, - Error: api.PtrTo("DKIM record appears malformed"), - } - } - - return &api.DKIMRecord{ - Selector: selector, - Domain: domain, - Record: &dkimRecord, - Valid: true, - } -} - -// validateDKIM performs basic DKIM record validation -func (d *DNSAnalyzer) validateDKIM(record string) bool { - // Should contain p= tag (public key) - if !strings.Contains(record, "p=") { - return false - } - - // Often contains v=DKIM1 but not required - // If v= is present, it should be DKIM1 - if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { - return false - } - - return true -} - -// checkapi.DMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { - // DMARC records are at: _dmarc.domain - dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) - if err != nil { - return &api.DMARCRecord{ - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), - } - } - - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break - } - } - - if dmarcRecord == "" { - return &api.DMARCRecord{ - Valid: false, - Error: api.PtrTo("No DMARC record found"), - } - } - - // Extract policy - policy := d.extractDMARCPolicy(dmarcRecord) - - // Extract subdomain policy - subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) - - // Extract percentage - percentage := d.extractDMARCPercentage(dmarcRecord) - - // Extract alignment modes - spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) - dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) - - // Basic validation - if !d.validateDMARC(dmarcRecord) { - return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: false, - Error: api.PtrTo("DMARC record appears malformed"), - } - } - - return &api.DMARCRecord{ - Record: &dmarcRecord, - Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: true, - } -} - -// extractDMARCPolicy extracts the policy from a DMARC record -func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { - // Look for p=none, p=quarantine, or p=reject - re := regexp.MustCompile(`p=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "unknown" -} - -// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { - // Look for aspf=s (strict) or aspf=r (relaxed) - re := regexp.MustCompile(`aspf=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) - } - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) - } - // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) -} - -// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { - // Look for adkim=s (strict) or adkim=r (relaxed) - re := regexp.MustCompile(`adkim=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) - } - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) - } - // Default is relaxed if not specified - return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) -} - -// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record -// Returns the sp tag value or nil if not specified (defaults to main policy) -func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { - // Look for sp=none, sp=quarantine, or sp=reject - re := regexp.MustCompile(`sp=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) - } - // If sp is not specified, it defaults to the main policy (p tag) - // Return nil to indicate it's using the default - return nil -} - -// extractDMARCPercentage extracts the percentage from a DMARC record -// Returns the pct tag value or nil if not specified (defaults to 100) -func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { - // Look for pct= - re := regexp.MustCompile(`pct=(\d+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - // Convert string to int - var pct int - fmt.Sscanf(matches[1], "%d", &pct) - // Validate range (0-100) - if pct >= 0 && pct <= 100 { - return &pct - } - } - // Default is 100 if not specified - return nil -} - -// validateDMARC performs basic DMARC record validation -func (d *DNSAnalyzer) validateDMARC(record string) bool { - // Must start with v=DMARC1 - if !strings.HasPrefix(record, "v=DMARC1") { - return false - } - - // Must have a policy tag - if !strings.Contains(record, "p=") { - return false - } - - return true -} - -// checkBIMIRecord looks up and validates BIMI record for a domain and selector -func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { - // BIMI records are at: selector._bimi.domain - bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) - - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) - if err != nil { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), - } - } - - if len(txtRecords) == 0 { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: api.PtrTo("No BIMI record found"), - } - } - - // Concatenate all TXT record parts (BIMI can be split) - bimiRecord := strings.Join(txtRecords, "") - - // Extract logo URL and VMC URL - logoURL := d.extractBIMITag(bimiRecord, "l") - vmcURL := d.extractBIMITag(bimiRecord, "a") - - // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) - if !d.validateBIMI(bimiRecord) { - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Record: &bimiRecord, - LogoUrl: &logoURL, - VmcUrl: &vmcURL, - Valid: false, - Error: api.PtrTo("BIMI record appears malformed"), - } - } - - return &api.BIMIRecord{ - Selector: selector, - Domain: domain, - Record: &bimiRecord, - LogoUrl: &logoURL, - VmcUrl: &vmcURL, - Valid: true, - } -} - -// extractBIMITag extracts a tag value from a BIMI record -func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { - // Look for tag=value pattern - re := regexp.MustCompile(tag + `=([^;]+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return strings.TrimSpace(matches[1]) - } - return "" -} - -// validateBIMI performs basic BIMI record validation -func (d *DNSAnalyzer) validateBIMI(record string) bool { - // Must start with v=BIMI1 - if !strings.HasPrefix(record, "v=BIMI1") { - return false - } - - // Must have a logo URL tag (l=) - if !strings.Contains(record, "l=") { - return false - } - - return true -} - -// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) -// Returns PTR hostnames and their corresponding forward-resolved IPs -func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - defer cancel() - - // Perform reverse DNS lookup (PTR) - ptrNames, err := d.resolver.LookupAddr(ctx, ip) - if err != nil || len(ptrNames) == 0 { - return nil, nil - } - - var forwardIPs []string - seenIPs := make(map[string]bool) - - // For each PTR record, perform forward DNS lookup (A/AAAA) - for _, ptrName := range ptrNames { - // Look up A records - ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) - aRecords, err := d.resolver.LookupHost(ctx, ptrName) - cancel() - - if err == nil { - for _, forwardIP := range aRecords { - if !seenIPs[forwardIP] { - forwardIPs = append(forwardIPs, forwardIP) - seenIPs[forwardIP] = true - } - } - } - } - - return ptrNames, forwardIPs -} - // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better // senderIP is the original sender IP address used for FCrDNS verification @@ -703,210 +135,21 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string score := 0 // PTR and Forward DNS: 20 points - // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability - if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { - // 10 points for having PTR records - score += 10 - - if len(*results.PtrRecords) > 1 { - // Penalty has it's bad to have multiple PTR records - score -= 3 - } - - // Additional 10 points for forward-confirmed reverse DNS (FCrDNS) - // This means the PTR hostname resolves back to IPs that include the original sender IP - if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { - // Verify that the sender IP is in the list of forward-resolved IPs - fcrDnsValid := false - for _, forwardIP := range *results.PtrForwardRecords { - if forwardIP == senderIP { - fcrDnsValid = true - break - } - } - if fcrDnsValid { - score += 10 - } - } - } + score += 20 * d.calculatePTRScore(results, senderIP) / 100 // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) - // Having valid MX records is critical for email deliverability - // From domain MX records (10 points) - needed for replies - if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { - hasValidFromMX := false - for _, mx := range *results.FromMxRecords { - if mx.Valid { - hasValidFromMX = true - break - } - } - if hasValidFromMX { - score += 10 - } - } - - // Return-Path domain MX records (10 points) - needed for bounces - if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { - hasValidRpMX := false - for _, mx := range *results.RpMxRecords { - if mx.Valid { - hasValidRpMX = true - break - } - } - if hasValidRpMX { - score += 10 - } - } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { - // If Return-Path domain is different but has no MX records, it's a problem - // Don't deduct points if RP domain is same as From domain (already checked) - } else { - // If Return-Path is same as From domain, give full 10 points for RP MX - if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { - hasValidFromMX := false - for _, mx := range *results.FromMxRecords { - if mx.Valid { - hasValidFromMX = true - break - } - } - if hasValidFromMX { - score += 10 - } - } - } + score += 20 * d.calculateMXScore(results) / 100 // SPF Records: 20 points - // SPF is essential for email authentication - if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { - // Find the main SPF record by skipping redirects - // Loop through records to find the last redirect or the first non-redirect - mainSPFIndex := 0 - for i := 0; i < len(*results.SpfRecords); i++ { - spfRecord := (*results.SpfRecords)[i] - if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { - // This is a redirect, check if there's a next record - if i+1 < len(*results.SpfRecords) { - mainSPFIndex = i + 1 - } else { - // Redirect exists but no target record found - break - } - } else { - // Found a non-redirect record - mainSPFIndex = i - break - } - } - - mainSPF := (*results.SpfRecords)[mainSPFIndex] - if mainSPF.Valid { - // Full points for valid SPF - score += 15 - - // Deduct points based on the all mechanism qualifier - if mainSPF.AllQualifier != nil { - switch *mainSPF.AllQualifier { - case "-": - // Strict fail - no deduction, this is the recommended policy - score += 5 - case "~": - // Softfail - moderate penalty - case "+", "?": - // Pass/neutral - severe penalty - score -= 5 - } - } else { - // No 'all' mechanism qualifier extracted - severe penalty - score -= 5 - } - } else if mainSPF.Record != nil { - // Partial credit if SPF record exists but has issues - score += 5 - } - } + score += 20 * d.calculateSPFScore(results) / 100 // DKIM Records: 20 points - // DKIM provides strong email authentication - if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { - hasValidDKIM := false - for _, dkim := range *results.DkimRecords { - if dkim.Valid { - hasValidDKIM = true - break - } - } - if hasValidDKIM { - score += 20 - } else { - // Partial credit if DKIM record exists but has issues - score += 5 - } - } + score += 20 * d.calculateDKIMScore(results) / 100 // DMARC Record: 20 points - // DMARC ties SPF and DKIM together and provides policy - if results.DmarcRecord != nil { - if results.DmarcRecord.Valid { - score += 10 - // Bonus points for stricter policies - if results.DmarcRecord.Policy != nil { - switch *results.DmarcRecord.Policy { - case "reject": - // Strictest policy - full points already awarded - score += 5 - case "quarantine": - // Good policy - no deduction - case "none": - // Weakest policy - deduct 5 points - score -= 5 - } - } - // Bonus points for strict alignment modes (2 points each) - if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { - score += 1 - } - if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { - score += 1 - } - // Subdomain policy scoring (sp tag) - // +3 for stricter or equal subdomain policy, -3 for weaker - if results.DmarcRecord.SubdomainPolicy != nil { - mainPolicy := string(*results.DmarcRecord.Policy) - subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + score += 20 * d.calculateDMARCScore(results) / 100 - // Policy strength: none < quarantine < reject - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - - mainStrength := policyStrength[mainPolicy] - subStrength := policyStrength[subPolicy] - - if subStrength >= mainStrength { - // Subdomain policy is equal or stricter - score += 3 - } else { - // Subdomain policy is weaker - score -= 3 - } - } else { - // No sp tag means subdomains inherit main policy (good default) - score += 3 - } - // Percentage scoring (pct tag) - // Apply the percentage on the current score - if results.DmarcRecord.Percentage != nil { - pct := *results.DmarcRecord.Percentage - - score = score * pct / 100 - } - } else if results.DmarcRecord.Record != nil { - // Partial credit if DMARC record exists but has issues - score += 5 - } - } - - // BIMI Record: 5 bonus points + // BIMI Record // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go new file mode 100644 index 0000000..44240e9 --- /dev/null +++ b/pkg/analyzer/dns_bimi.go @@ -0,0 +1,114 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No BIMI record found"), + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: false, + Error: api.PtrTo("BIMI record appears malformed"), + } + } + + return &api.BIMIRecord{ + Selector: selector, + Domain: domain, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} diff --git a/pkg/analyzer/dns_bimi_test.go b/pkg/analyzer/dns_bimi_test.go new file mode 100644 index 0000000..cf7df83 --- /dev/null +++ b/pkg/analyzer/dns_bimi_test.go @@ -0,0 +1,128 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + "time" +) + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go new file mode 100644 index 0000000..7ac858d --- /dev/null +++ b/pkg/analyzer/dns_dkim.go @@ -0,0 +1,116 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { + // DKIM records are at: selector._domainkey.domain + dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) + if err != nil { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + } + } + + if len(txtRecords) == 0 { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: api.PtrTo("No DKIM record found"), + } + } + + // Concatenate all TXT record parts (DKIM can be split) + dkimRecord := strings.Join(txtRecords, "") + + // Basic validation - should contain "v=DKIM1" and "p=" (public key) + if !d.validateDKIM(dkimRecord) { + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: api.PtrTo(dkimRecord), + Valid: false, + Error: api.PtrTo("DKIM record appears malformed"), + } + } + + return &api.DKIMRecord{ + Selector: selector, + Domain: domain, + Record: &dkimRecord, + Valid: true, + } +} + +// validateDKIM performs basic DKIM record validation +func (d *DNSAnalyzer) validateDKIM(record string) bool { + // Should contain p= tag (public key) + if !strings.Contains(record, "p=") { + return false + } + + // Often contains v=DKIM1 but not required + // If v= is present, it should be DKIM1 + if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) { + // DKIM provides strong email authentication + if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { + hasValidDKIM := false + for _, dkim := range *results.DkimRecords { + if dkim.Valid { + hasValidDKIM = true + break + } + } + if hasValidDKIM { + score += 100 + } else { + // Partial credit if DKIM record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go new file mode 100644 index 0000000..8d94d20 --- /dev/null +++ b/pkg/analyzer/dns_dkim_test.go @@ -0,0 +1,72 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + "time" +) + +func TestValidateDKIM(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DKIM with version", + record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Valid DKIM without version", + record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Invalid DKIM - no public key", + record: "v=DKIM1; k=rsa", + expected: false, + }, + { + name: "Invalid DKIM - wrong version", + record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: false, + }, + { + name: "Invalid DKIM - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDKIM(tt.record) + if result != tt.expected { + t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go new file mode 100644 index 0000000..3b73ecc --- /dev/null +++ b/pkg/analyzer/dns_dmarc.go @@ -0,0 +1,256 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkapi.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { + // DMARC records are at: _dmarc.domain + dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) + if err != nil { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + + // Find DMARC record (starts with "v=DMARC1") + var dmarcRecord string + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + dmarcRecord = txt + break + } + } + + if dmarcRecord == "" { + return &api.DMARCRecord{ + Valid: false, + Error: api.PtrTo("No DMARC record found"), + } + } + + // Extract policy + policy := d.extractDMARCPolicy(dmarcRecord) + + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + + // Basic validation + if !d.validateDMARC(dmarcRecord) { + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: api.PtrTo("DMARC record appears malformed"), + } + } + + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, + } +} + +// extractDMARCPolicy extracts the policy from a DMARC record +func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { + // Look for p=none, p=quarantine, or p=reject + re := regexp.MustCompile(`p=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment { + // Look for aspf=s (strict) or aspf=r (relaxed) + re := regexp.MustCompile(`aspf=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordSpfAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment { + // Look for adkim=s (strict) or adkim=r (relaxed) + re := regexp.MustCompile(`adkim=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return api.PtrTo(api.DMARCRecordDkimAlignmentStrict) + } + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed) +} + +// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record +// Returns the sp tag value or nil if not specified (defaults to main policy) +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy { + // Look for sp=none, sp=quarantine, or sp=reject + re := regexp.MustCompile(`sp=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + +// validateDMARC performs basic DMARC record validation +func (d *DNSAnalyzer) validateDMARC(record string) bool { + // Must start with v=DMARC1 + if !strings.HasPrefix(record, "v=DMARC1") { + return false + } + + // Must have a policy tag + if !strings.Contains(record, "p=") { + return false + } + + return true +} + +func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) { + // DMARC ties SPF and DKIM together and provides policy + if results.DmarcRecord != nil { + if results.DmarcRecord.Valid { + score += 50 + // Bonus points for stricter policies + if results.DmarcRecord.Policy != nil { + switch *results.DmarcRecord.Policy { + case "reject": + // Strictest policy - full points already awarded + score += 25 + case "quarantine": + // Good policy - no deduction + case "none": + // Weakest policy - deduct 5 points + score -= 25 + } + } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict { + score += 5 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict { + score += 5 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 15 + } else { + // Subdomain policy is weaker + score -= 15 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 15 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } + } else if results.DmarcRecord.Record != nil { + // Partial credit if DMARC record exists but has issues + score += 20 + } + } + + return +} diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go new file mode 100644 index 0000000..0868e48 --- /dev/null +++ b/pkg/analyzer/dns_dmarc_test.go @@ -0,0 +1,343 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestExtractDMARCPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy string + }{ + { + name: "Policy none", + record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + expectedPolicy: "none", + }, + { + name: "Policy quarantine", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPolicy: "quarantine", + }, + { + name: "Policy reject", + record: "v=DMARC1; p=reject; sp=reject", + expectedPolicy: "reject", + }, + { + name: "No policy", + record: "v=DMARC1", + expectedPolicy: "unknown", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPolicy(tt.record) + if result != tt.expectedPolicy { + t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + } + }) + } +} + +func TestValidateDMARC(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DMARC", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Valid DMARC minimal", + record: "v=DMARC1; p=none", + expected: true, + }, + { + name: "Invalid DMARC - no version", + record: "p=quarantine", + expected: false, + }, + { + name: "Invalid DMARC - no policy", + record: "v=DMARC1", + expected: false, + }, + { + name: "Invalid DMARC - wrong version", + record: "v=DMARC2; p=reject", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDMARC(tt.record) + if result != tt.expected { + t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractDMARCSPFAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", + }, + { + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", + }, + { + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCDKIMAlignment(t *testing.T) { + tests := []struct { + name string + record string + expectedAlignment string + }{ + { + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", + }, + { + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", + }, + { + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) + } + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: api.PtrTo("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: api.PtrTo("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: api.PtrTo("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: api.PtrTo("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + +func TestExtractDMARCPercentage(t *testing.T) { + tests := []struct { + name string + record string + expectedPercentage *int + }{ + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: api.PtrTo(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: api.PtrTo(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: api.PtrTo(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: api.PtrTo(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: api.PtrTo(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPercentage(tt.record) + if tt.expectedPercentage == nil { + if result != nil { + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + } + if *result != *tt.expectedPercentage { + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + } + } + }) + } +} diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go new file mode 100644 index 0000000..f90e5dc --- /dev/null +++ b/pkg/analyzer/dns_fcr.go @@ -0,0 +1,94 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) +// Returns PTR hostnames and their corresponding forward-resolved IPs +func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + // Perform reverse DNS lookup (PTR) + ptrNames, err := d.resolver.LookupAddr(ctx, ip) + if err != nil || len(ptrNames) == 0 { + return nil, nil + } + + var forwardIPs []string + seenIPs := make(map[string]bool) + + // For each PTR record, perform forward DNS lookup (A/AAAA) + for _, ptrName := range ptrNames { + // Look up A records + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + aRecords, err := d.resolver.LookupHost(ctx, ptrName) + cancel() + + if err == nil { + for _, forwardIP := range aRecords { + if !seenIPs[forwardIP] { + forwardIPs = append(forwardIPs, forwardIP) + seenIPs[forwardIP] = true + } + } + } + } + + return ptrNames, forwardIPs +} + +// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability +func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) { + if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { + // 50 points for having PTR records + score += 50 + + if len(*results.PtrRecords) > 1 { + // Penalty has it's bad to have multiple PTR records + score -= 15 + } + + // Additional 50 points for forward-confirmed reverse DNS (FCrDNS) + // This means the PTR hostname resolves back to IPs that include the original sender IP + if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { + // Verify that the sender IP is in the list of forward-resolved IPs + fcrDnsValid := false + for _, forwardIP := range *results.PtrForwardRecords { + if forwardIP == senderIP { + fcrDnsValid = true + break + } + } + if fcrDnsValid { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go new file mode 100644 index 0000000..68e55b5 --- /dev/null +++ b/pkg/analyzer/dns_mx.go @@ -0,0 +1,115 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkMXRecords looks up MX records for a domain +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + mxRecords, err := d.resolver.LookupMX(ctx, domain) + if err != nil { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + }, + } + } + + if len(mxRecords) == 0 { + return &[]api.MXRecord{ + { + Valid: false, + Error: api.PtrTo("No MX records found"), + }, + } + } + + var results []api.MXRecord + for _, mx := range mxRecords { + results = append(results, api.MXRecord{ + Host: mx.Host, + Priority: mx.Pref, + Valid: true, + }) + } + + return &results +} + +func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) { + // Having valid MX records is critical for email deliverability + // From domain MX records (half points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 50 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 50 + } + } + } + + return +} diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go new file mode 100644 index 0000000..bc7a1be --- /dev/null +++ b/pkg/analyzer/dns_spf.go @@ -0,0 +1,268 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives +func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { + visited := make(map[string]bool) + return d.resolveSPFRecords(domain, visited, 0) +} + +// resolveSPFRecords recursively resolves SPF records including include: directives +func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { + const maxDepth = 10 // Prevent infinite recursion + + if depth > maxDepth { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("Maximum SPF include depth exceeded"), + }, + } + } + + // Prevent circular references + if visited[domain] { + return &[]api.SPFRecord{} + } + visited[domain] = true + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, domain) + if err != nil { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + }, + } + } + + // Find SPF record (starts with "v=spf1") + var spfRecord string + spfCount := 0 + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=spf1") { + spfRecord = txt + spfCount++ + } + } + + if spfCount == 0 { + return &[]api.SPFRecord{ + { + Domain: &domain, + Valid: false, + Error: api.PtrTo("No SPF record found"), + }, + } + } + + var results []api.SPFRecord + + if spfCount > 1 { + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: false, + Error: api.PtrTo("Multiple SPF records found (RFC violation)"), + }) + return &results + } + + // Basic validation + valid := d.validateSPF(spfRecord) + + // Extract the "all" mechanism qualifier + var allQualifier *api.SPFRecordAllQualifier + var errMsg *string + + if !valid { + errMsg = api.PtrTo("SPF record appears malformed") + } else { + // Extract qualifier from the "all" mechanism + if strings.HasSuffix(spfRecord, " -all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) + } else if strings.HasSuffix(spfRecord, " ~all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) + } else if strings.HasSuffix(spfRecord, " +all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } else if strings.HasSuffix(spfRecord, " ?all") { + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) + } else if strings.HasSuffix(spfRecord, " all") { + // Implicit + qualifier (default) + allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) + } + } + + results = append(results, api.SPFRecord{ + Domain: &domain, + Record: &spfRecord, + Valid: valid, + AllQualifier: allQualifier, + Error: errMsg, + }) + + // Check for redirect= modifier first (it replaces the entire SPF policy) + redirectDomain := d.extractSPFRedirect(spfRecord) + if redirectDomain != "" { + // redirect= replaces the current domain's policy entirely + // Only follow if no other mechanisms matched (per RFC 7208) + redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) + if redirectRecords != nil { + results = append(results, *redirectRecords...) + } + return &results + } + + // Extract and resolve include: directives + includes := d.extractSPFIncludes(spfRecord) + for _, includeDomain := range includes { + includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) + if includedRecords != nil { + results = append(results, *includedRecords...) + } + } + + return &results +} + +// extractSPFIncludes extracts all include: domains from an SPF record +func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { + var includes []string + re := regexp.MustCompile(`include:([^\s]+)`) + matches := re.FindAllStringSubmatch(record, -1) + for _, match := range matches { + if len(match) > 1 { + includes = append(includes, match[1]) + } + } + return includes +} + +// extractSPFRedirect extracts the redirect= domain from an SPF record +// The redirect= modifier replaces the current domain's SPF policy with that of the target domain +func (d *DNSAnalyzer) extractSPFRedirect(record string) string { + re := regexp.MustCompile(`redirect=([^\s]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// validateSPF performs basic SPF record validation +func (d *DNSAnalyzer) validateSPF(record string) bool { + // Must start with v=spf1 + if !strings.HasPrefix(record, "v=spf1") { + return false + } + + // Check for redirect= modifier (which replaces the need for an 'all' mechanism) + if strings.Contains(record, "redirect=") { + return true + } + + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } + } + + return hasValidEnding +} + +// hasSPFStrictFail checks if SPF record has strict -all mechanism +func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { + return strings.HasSuffix(record, " -all") +} + +func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { + // SPF is essential for email authentication + if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { + // Find the main SPF record by skipping redirects + // Loop through records to find the last redirect or the first non-redirect + mainSPFIndex := 0 + for i := 0; i < len(*results.SpfRecords); i++ { + spfRecord := (*results.SpfRecords)[i] + if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { + // This is a redirect, check if there's a next record + if i+1 < len(*results.SpfRecords) { + mainSPFIndex = i + 1 + } else { + // Redirect exists but no target record found + break + } + } else { + // Found a non-redirect record + mainSPFIndex = i + break + } + } + + mainSPF := (*results.SpfRecords)[mainSPFIndex] + if mainSPF.Valid { + // Full points for valid SPF + score += 75 + + // Deduct points based on the all mechanism qualifier + if mainSPF.AllQualifier != nil { + switch *mainSPF.AllQualifier { + case "-": + // Strict fail - no deduction, this is the recommended policy + score += 25 + case "~": + // Softfail - moderate penalty + case "+", "?": + // Pass/neutral - severe penalty + score -= 25 + } + } else { + // No 'all' mechanism qualifier extracted - severe penalty + score -= 25 + } + } else if mainSPF.Record != nil { + // Partial credit if SPF record exists but has issues + score += 25 + } + } + + return +} diff --git a/pkg/analyzer/dns_spf_test.go b/pkg/analyzer/dns_spf_test.go new file mode 100644 index 0000000..132f063 --- /dev/null +++ b/pkg/analyzer/dns_spf_test.go @@ -0,0 +1,137 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "testing" + "time" +) + +func TestValidateSPF(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid SPF with -all", + record: "v=spf1 include:_spf.example.com -all", + expected: true, + }, + { + name: "Valid SPF with ~all", + record: "v=spf1 ip4:192.0.2.0/24 ~all", + expected: true, + }, + { + name: "Valid SPF with +all", + record: "v=spf1 +all", + expected: true, + }, + { + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expected: true, + }, + { + name: "Valid SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expected: true, + }, + { + name: "Valid SPF with redirect and mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", + expected: true, + }, + { + name: "Invalid SPF - no version", + record: "include:_spf.example.com -all", + expected: false, + }, + { + name: "Invalid SPF - no all mechanism or redirect", + record: "v=spf1 include:_spf.example.com", + expected: false, + }, + { + name: "Invalid SPF - wrong version", + record: "v=spf2 include:_spf.example.com -all", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateSPF(tt.record) + if result != tt.expected { + t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractSPFRedirect(t *testing.T) { + tests := []struct { + name string + record string + expectedRedirect string + }{ + { + name: "SPF with redirect", + record: "v=spf1 redirect=_spf.example.com", + expectedRedirect: "_spf.example.com", + }, + { + name: "SPF with redirect and other mechanisms", + record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", + expectedRedirect: "_spf.google.com", + }, + { + name: "SPF without redirect", + record: "v=spf1 include:_spf.example.com -all", + expectedRedirect: "", + }, + { + name: "SPF with only all mechanism", + record: "v=spf1 -all", + expectedRedirect: "", + }, + { + name: "Empty record", + record: "", + expectedRedirect: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractSPFRedirect(tt.record) + if result != tt.expectedRedirect { + t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) + } + }) + } +} diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 10b7b98..bba4503 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -56,581 +56,3 @@ func TestNewDNSAnalyzer(t *testing.T) { }) } } -func TestValidateSPF(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid SPF with -all", - record: "v=spf1 include:_spf.example.com -all", - expected: true, - }, - { - name: "Valid SPF with ~all", - record: "v=spf1 ip4:192.0.2.0/24 ~all", - expected: true, - }, - { - name: "Valid SPF with +all", - record: "v=spf1 +all", - expected: true, - }, - { - name: "Valid SPF with ?all", - record: "v=spf1 mx ?all", - expected: true, - }, - { - name: "Valid SPF with redirect", - record: "v=spf1 redirect=_spf.example.com", - expected: true, - }, - { - name: "Valid SPF with redirect and mechanisms", - record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com", - expected: true, - }, - { - name: "Invalid SPF - no version", - record: "include:_spf.example.com -all", - expected: false, - }, - { - name: "Invalid SPF - no all mechanism or redirect", - record: "v=spf1 include:_spf.example.com", - expected: false, - }, - { - name: "Invalid SPF - wrong version", - record: "v=spf2 include:_spf.example.com -all", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateSPF(tt.record) - if result != tt.expected { - t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractSPFRedirect(t *testing.T) { - tests := []struct { - name string - record string - expectedRedirect string - }{ - { - name: "SPF with redirect", - record: "v=spf1 redirect=_spf.example.com", - expectedRedirect: "_spf.example.com", - }, - { - name: "SPF with redirect and other mechanisms", - record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com", - expectedRedirect: "_spf.google.com", - }, - { - name: "SPF without redirect", - record: "v=spf1 include:_spf.example.com -all", - expectedRedirect: "", - }, - { - name: "SPF with only all mechanism", - record: "v=spf1 -all", - expectedRedirect: "", - }, - { - name: "Empty record", - record: "", - expectedRedirect: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractSPFRedirect(tt.record) - if result != tt.expectedRedirect { - t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect) - } - }) - } -} - -func TestValidateDKIM(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DKIM with version", - record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Valid DKIM without version", - record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: true, - }, - { - name: "Invalid DKIM - no public key", - record: "v=DKIM1; k=rsa", - expected: false, - }, - { - name: "Invalid DKIM - wrong version", - record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", - expected: false, - }, - { - name: "Invalid DKIM - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDKIM(tt.record) - if result != tt.expected { - t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractDMARCPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy string - }{ - { - name: "Policy none", - record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", - expectedPolicy: "none", - }, - { - name: "Policy quarantine", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPolicy: "quarantine", - }, - { - name: "Policy reject", - record: "v=DMARC1; p=reject; sp=reject", - expectedPolicy: "reject", - }, - { - name: "No policy", - record: "v=DMARC1", - expectedPolicy: "unknown", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) - } - }) - } -} - -func TestValidateDMARC(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid DMARC", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - expected: true, - }, - { - name: "Valid DMARC minimal", - record: "v=DMARC1; p=none", - expected: true, - }, - { - name: "Invalid DMARC - no version", - record: "p=quarantine", - expected: false, - }, - { - name: "Invalid DMARC - no policy", - record: "v=DMARC1", - expected: false, - }, - { - name: "Invalid DMARC - wrong version", - record: "v=DMARC2; p=reject", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateDMARC(tt.record) - if result != tt.expected { - t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractBIMITag(t *testing.T) { - tests := []struct { - name string - record string - tag string - expectedValue string - }{ - { - name: "Extract logo URL (l tag)", - record: "v=BIMI1; l=https://example.com/logo.svg", - tag: "l", - expectedValue: "https://example.com/logo.svg", - }, - { - name: "Extract VMC URL (a tag)", - record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", - tag: "a", - expectedValue: "https://example.com/vmc.pem", - }, - { - name: "Tag not found", - record: "v=BIMI1; l=https://example.com/logo.svg", - tag: "a", - expectedValue: "", - }, - { - name: "Tag with spaces", - record: "v=BIMI1; l= https://example.com/logo.svg ", - tag: "l", - expectedValue: "https://example.com/logo.svg", - }, - { - name: "Empty record", - record: "", - tag: "l", - expectedValue: "", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractBIMITag(tt.record, tt.tag) - if result != tt.expectedValue { - t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) - } - }) - } -} - -func TestValidateBIMI(t *testing.T) { - tests := []struct { - name string - record string - expected bool - }{ - { - name: "Valid BIMI with logo URL", - record: "v=BIMI1; l=https://example.com/logo.svg", - expected: true, - }, - { - name: "Valid BIMI with logo and VMC", - record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", - expected: true, - }, - { - name: "Invalid BIMI - no version", - record: "l=https://example.com/logo.svg", - expected: false, - }, - { - name: "Invalid BIMI - wrong version", - record: "v=BIMI2; l=https://example.com/logo.svg", - expected: false, - }, - { - name: "Invalid BIMI - no logo URL", - record: "v=BIMI1", - expected: false, - }, - { - name: "Invalid BIMI - empty", - record: "", - expected: false, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.validateBIMI(tt.record) - if result != tt.expected { - t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) - } - }) - } -} - -func TestExtractDMARCSPFAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "SPF alignment - strict", - record: "v=DMARC1; p=quarantine; aspf=s", - expectedAlignment: "strict", - }, - { - name: "SPF alignment - relaxed (explicit)", - record: "v=DMARC1; p=quarantine; aspf=r", - expectedAlignment: "relaxed", - }, - { - name: "SPF alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=quarantine", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check SPF strict", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check SPF relaxed", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with SPF strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSPFAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCDKIMAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "DKIM alignment - strict", - record: "v=DMARC1; p=reject; adkim=s", - expectedAlignment: "strict", - }, - { - name: "DKIM alignment - relaxed (explicit)", - record: "v=DMARC1; p=reject; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "DKIM alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=none", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check DKIM strict", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check DKIM relaxed", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with DKIM strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCDKIMAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCSubdomainPolicy(t *testing.T) { - tests := []struct { - name string - record string - expectedPolicy *string - }{ - { - name: "Subdomain policy - none", - record: "v=DMARC1; p=quarantine; sp=none", - expectedPolicy: stringPtr("none"), - }, - { - name: "Subdomain policy - quarantine", - record: "v=DMARC1; p=reject; sp=quarantine", - expectedPolicy: stringPtr("quarantine"), - }, - { - name: "Subdomain policy - reject", - record: "v=DMARC1; p=quarantine; sp=reject", - expectedPolicy: stringPtr("reject"), - }, - { - name: "No subdomain policy specified (defaults to main policy)", - record: "v=DMARC1; p=quarantine", - expectedPolicy: nil, - }, - { - name: "Complex record with subdomain policy", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: stringPtr("quarantine"), - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSubdomainPolicy(tt.record) - if tt.expectedPolicy == nil { - if result != nil { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) - } - } else { - if result == nil { - t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) - } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) - } - } - }) - } -} - -func TestExtractDMARCPercentage(t *testing.T) { - tests := []struct { - name string - record string - expectedPercentage *int - }{ - { - name: "Percentage - 100", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPercentage: intPtr(100), - }, - { - name: "Percentage - 50", - record: "v=DMARC1; p=quarantine; pct=50", - expectedPercentage: intPtr(50), - }, - { - name: "Percentage - 25", - record: "v=DMARC1; p=reject; pct=25", - expectedPercentage: intPtr(25), - }, - { - name: "Percentage - 0", - record: "v=DMARC1; p=none; pct=0", - expectedPercentage: intPtr(0), - }, - { - name: "No percentage specified (defaults to 100)", - record: "v=DMARC1; p=quarantine", - expectedPercentage: nil, - }, - { - name: "Complex record with percentage", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", - expectedPercentage: intPtr(75), - }, - { - name: "Invalid percentage > 100 (ignored)", - record: "v=DMARC1; p=quarantine; pct=150", - expectedPercentage: nil, - }, - { - name: "Invalid percentage < 0 (ignored)", - record: "v=DMARC1; p=quarantine; pct=-10", - expectedPercentage: nil, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPercentage(tt.record) - if tt.expectedPercentage == nil { - if result != nil { - t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) - } - } else { - if result == nil { - t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) - } - if *result != *tt.expectedPercentage { - t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) - } - } - }) - } -} - -// Helper functions for test pointers -func stringPtr(s string) *string { - return &s -} - -func intPtr(i int) *int { - return &i -}