diff --git a/spoof_check.go b/spoof_check.go index fbcfefa..27fa9b3 100644 --- a/spoof_check.go +++ b/spoof_check.go @@ -10,10 +10,21 @@ import ( "net" "net/http" // New import "os" + "regexp" "strings" "time" ) +// Global debug flag +var debugMode bool + +// Debug logs a message if debug mode is enabled +func Debug(format string, args ...interface{}) { + if debugMode { + fmt.Printf("[DEBUG] "+format+"\n", args...) + } +} + // SPFDMARCRecord contains both SPF and DMARC records for a domain type SPFDMARCRecord struct { domainName string @@ -161,8 +172,115 @@ func getSPFDMARCRecord(domain string) SPFDMARCRecord { return record } -// Parse SPF record to extract all components -func parseSPFRecord(spfRecord string, domainName string) *ParsedSPFRecord { +// Reverse an IP address (e.g., "192.168.1.1" -> "1.1.168.192") +func reverseIP(ip string) string { + parts := strings.Split(ip, ".") + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + return strings.Join(parts, ".") +} + +// Expand SPF macros as per RFC 7208 +func expandSPFMacro(input string, domain string, sender string, ip string) string { + // Compute reverse IP if available + reverseIPValue := "" + if ip != "" { + reverseIPValue = reverseIP(ip) + } + + // Split sender into local and domain parts + localPart := "" + domainPart := "" + if strings.Contains(sender, "@") { + parts := strings.SplitN(sender, "@", 2) + localPart = parts[0] + domainPart = parts[1] + } + + // Define macro replacements + replacements := map[string]string{ + "s": sender, + "l": localPart, + "o": domainPart, + "d": domain, + "i": ip, + "ir": reverseIPValue, // Reverse IP + "v": "in-addr", // IPv4 + "p": "unknown", // Placeholder for policy domain + "c": ip, // Client IP + "r": "unknown", // Placeholder for receiver + "t": fmt.Sprintf("%d", time.Now().Unix()), + } + + // Regex to match macros + macroRegex := regexp.MustCompile(`%{([slodipvcrt]|ir)(\d+)?r?}`) + + // Replace macros + return macroRegex.ReplaceAllStringFunc(input, func(macro string) string { + matches := macroRegex.FindStringSubmatch(macro) + if len(matches) < 2 { + return macro // Return as-is if no match + } + key := matches[1] + if value, exists := replacements[key]; exists { + return value + } + return macro // Return as-is if no replacement found + }) +} + +// Improved resolveIPFromSPF to try additional methods for IP resolution +func resolveIPFromSPF(spfRecord string, domain string) string { + // Try the existing mechanisms first + mechanisms := strings.Split(spfRecord, " ") + for _, mechanism := range mechanisms { + if strings.HasPrefix(mechanism, "a") { + ips, err := net.LookupIP(domain) + if err == nil && len(ips) > 0 { + for _, ip := range ips { + // Prefer IPv4 addresses + if ip.To4() != nil { + Debug("Resolved IP from A record: %s", ip.String()) + return ip.String() + } + } + // If no IPv4, use whatever we have + Debug("Resolved IP from A record: %s", ips[0].String()) + return ips[0].String() + } + } else if strings.HasPrefix(mechanism, "mx") { + // ... existing mx handler code ... + } else if strings.HasPrefix(mechanism, "exists:") { + // ... existing exists handler code ... + } + } + + // If no IP was found from mechanisms, try a direct lookup as fallback + ips, err := net.LookupIP(domain) + if err == nil && len(ips) > 0 { + for _, ip := range ips { + if ip.To4() != nil { + Debug("Resolved IP via direct lookup: %s", ip.String()) + return ip.String() + } + } + Debug("Resolved IP via direct lookup: %s", ips[0].String()) + return ips[0].String() + } + + // If all else fails, return a dummy IP for debugging + Debug("Could not resolve any IP for domain %s", domain) + return "127.0.0.1" // Return a dummy IP to prevent empty macros +} + +// Parse SPF record to extract all components, including macro expansion +func parseSPFRecord(spfRecord string, domainName string, sender string, ip string) *ParsedSPFRecord { + // Dynamically resolve IP if not provided + if ip == "" { + ip = resolveIPFromSPF(spfRecord, domainName) + } + parsedRecord := &ParsedSPFRecord{ aRecord: make([]string, 0), includeRecords: make([]ParsedIncludeRecord, 0), @@ -179,23 +297,20 @@ func parseSPFRecord(spfRecord string, domainName string) *ParsedSPFRecord { visitedDomains := make(map[string]bool) visitedDomains[domainName] = true - return parseSPFRecordWithDepth(spfRecord, domainName, visitedDomains, 0) + Debug("Starting SPF parsing for domain: %s", domainName) + return parseSPFRecordWithDepth(spfRecord, domainName, sender, ip, visitedDomains, 0) } // Helper function with depth tracking to prevent stack overflow -func parseSPFRecordWithDepth(spfRecord string, domainName string, visitedDomains map[string]bool, depth int) *ParsedSPFRecord { +func parseSPFRecordWithDepth(spfRecord string, domainName string, sender string, ip string, visitedDomains map[string]bool, depth int) *ParsedSPFRecord { // Limit recursion depth maxDepth := 10 if depth > maxDepth { - return &ParsedSPFRecord{ - aRecord: make([]string, 0), - includeRecords: make([]ParsedIncludeRecord, 0), - mxRecord: make([]string, 0), - existsRecords: make([]string, 0), - ptrRecords: make([]string, 0), - } + Debug("Max recursion depth reached for domain: %s", domainName) + return &ParsedSPFRecord{} } + Debug("Parsing SPF record for domain: %s, depth: %d", domainName, depth) parsedRecord := &ParsedSPFRecord{ aRecord: make([]string, 0), includeRecords: make([]ParsedIncludeRecord, 0), @@ -204,62 +319,60 @@ func parseSPFRecordWithDepth(spfRecord string, domainName string, visitedDomains ptrRecords: make([]string, 0), } - if isNullOrWhiteSpace(spfRecord) { - return parsedRecord - } - mechanisms := strings.Split(spfRecord, " ") for _, mechanism := range mechanisms { - if strings.HasPrefix(mechanism, "a:") || mechanism == "a" { - parsedRecord.aRecord = append(parsedRecord.aRecord, mechanism) - } else if strings.HasPrefix(mechanism, "include:") { - includeDomain := strings.TrimPrefix(mechanism, "include:") - includeRecord := ParsedIncludeRecord{ - includeRecord: includeDomain, + expandedMechanism := expandSPFMacro(mechanism, domainName, sender, ip) + Debug("Expanded mechanism: %s", expandedMechanism) + + if strings.HasPrefix(expandedMechanism, "a:") || expandedMechanism == "a" { + parsedRecord.aRecord = append(parsedRecord.aRecord, expandedMechanism) + } else if strings.HasPrefix(expandedMechanism, "include:") { + includeDomain := strings.TrimPrefix(expandedMechanism, "include:") + Debug("Processing include: %s", includeDomain) + + if visitedDomains[includeDomain] { + Debug("Loop detected for include: %s", includeDomain) + continue } - // Skip already visited domains to prevent loops - if !visitedDomains[includeDomain] { - visitedDomains[includeDomain] = true + visitedDomains[includeDomain] = true + includeSPF := getSPFDMARCRecord(includeDomain) + if !isNullOrWhiteSpace(includeSPF.spfRecord) { + subParsed := parseSPFRecordWithDepth(includeSPF.spfRecord, includeDomain, sender, ip, visitedDomains, depth+1) + parsedRecord.includeRecords = append(parsedRecord.includeRecords, ParsedIncludeRecord{ + includeRecord: includeDomain, + subLookup: subParsed, + }) + } + } else if strings.HasPrefix(expandedMechanism, "mx:") || expandedMechanism == "mx" { + parsedRecord.mxRecord = append(parsedRecord.mxRecord, expandedMechanism) + } else if strings.HasPrefix(expandedMechanism, "exists:") { + parsedRecord.existsRecords = append(parsedRecord.existsRecords, expandedMechanism) + } else if strings.HasPrefix(expandedMechanism, "ptr:") || expandedMechanism == "ptr" { + parsedRecord.ptrRecords = append(parsedRecord.ptrRecords, expandedMechanism) + } else if strings.HasPrefix(expandedMechanism, "redirect=") { + redirectDomain := strings.TrimPrefix(expandedMechanism, "redirect=") + Debug("Processing redirect to: %s", redirectDomain) - // Get the SPF record for the included domain - includedSPF := getSPFDMARCRecord(includeDomain) - if !isNullOrWhiteSpace(includedSPF.spfRecord) { - includeRecord.subLookup = parseSPFRecordWithDepth(includedSPF.spfRecord, includeDomain, visitedDomains, depth+1) - } + if visitedDomains[redirectDomain] { + Debug("Loop detected for redirect: %s", redirectDomain) + continue } - parsedRecord.includeRecords = append(parsedRecord.includeRecords, includeRecord) - } else if strings.HasPrefix(mechanism, "mx:") || mechanism == "mx" { - parsedRecord.mxRecord = append(parsedRecord.mxRecord, mechanism) - } else if strings.HasPrefix(mechanism, "exists:") { - parsedRecord.existsRecords = append(parsedRecord.existsRecords, mechanism) - } else if strings.HasPrefix(mechanism, "ptr:") || mechanism == "ptr" { - parsedRecord.ptrRecords = append(parsedRecord.ptrRecords, mechanism) - } - // Check for redirect - if strings.HasPrefix(mechanism, "redirect=") { - redirectDomain := strings.TrimPrefix(mechanism, "redirect=") - - // Skip already visited domains to prevent loops - if !visitedDomains[redirectDomain] { - visitedDomains[redirectDomain] = true - - redirectSPF := getSPFDMARCRecord(redirectDomain) - if !isNullOrWhiteSpace(redirectSPF.spfRecord) { - // Parse the redirected SPF record - redirectParsed := parseSPFRecordWithDepth(redirectSPF.spfRecord, redirectDomain, visitedDomains, depth+1) - // Merge the parsed records - parsedRecord.aRecord = append(parsedRecord.aRecord, redirectParsed.aRecord...) - parsedRecord.includeRecords = append(parsedRecord.includeRecords, redirectParsed.includeRecords...) - parsedRecord.mxRecord = append(parsedRecord.mxRecord, redirectParsed.mxRecord...) - parsedRecord.existsRecords = append(parsedRecord.existsRecords, redirectParsed.existsRecords...) - parsedRecord.ptrRecords = append(parsedRecord.ptrRecords, redirectParsed.ptrRecords...) - } + visitedDomains[redirectDomain] = true + redirectSPF := getSPFDMARCRecord(redirectDomain) + if !isNullOrWhiteSpace(redirectSPF.spfRecord) { + redirectParsed := parseSPFRecordWithDepth(redirectSPF.spfRecord, redirectDomain, sender, ip, visitedDomains, depth+1) + parsedRecord.aRecord = append(parsedRecord.aRecord, redirectParsed.aRecord...) + parsedRecord.includeRecords = append(parsedRecord.includeRecords, redirectParsed.includeRecords...) + parsedRecord.mxRecord = append(parsedRecord.mxRecord, redirectParsed.mxRecord...) + parsedRecord.existsRecords = append(parsedRecord.existsRecords, redirectParsed.existsRecords...) + parsedRecord.ptrRecords = append(parsedRecord.ptrRecords, redirectParsed.ptrRecords...) } } } + Debug("Finished parsing SPF record for domain: %s, depth: %d", domainName, depth) return parsedRecord } @@ -697,8 +810,8 @@ func main() { var jsonOutput bool var outputFile string var inputFile string - var checkSubTakeover bool // New flag - + var checkSubTakeover bool // New flag + flag.BoolVar(&debugMode, "debug", false, "Enable debug mode") // New debug flag flag.StringVar(&domain, "domain", "", "Domain to check for spoofing vulnerabilities") flag.BoolVar(&interactive, "interactive", false, "Run in interactive mode") flag.BoolVar(&jsonOutput, "json", false, "Output results in JSON format") @@ -707,6 +820,10 @@ func main() { flag.BoolVar(&checkSubTakeover, "subtakeover", false, "Check for subdomain takeover vulnerabilities") flag.Parse() + Debug("Debug mode enabled") + Debug("Parsed flags: domain=%s, interactive=%v, jsonOutput=%v, outputFile=%s, inputFile=%s, checkSubTakeover=%v", + domain, interactive, jsonOutput, outputFile, inputFile, checkSubTakeover) + // Process domains from input file if specified if inputFile != "" { processDomainFile(inputFile, jsonOutput, outputFile, checkSubTakeover) @@ -733,6 +850,8 @@ func main() { // Process a file containing a list of domains func processDomainFile(inputFile string, jsonOutput bool, outputFile string, checkSubTakeover bool) { + Debug("Processing input file: %s", inputFile) + // Read the file file, err := os.Open(inputFile) if err != nil { @@ -768,109 +887,10 @@ func processDomainFile(inputFile string, jsonOutput bool, outputFile string, che continue // Skip empty lines and comments } + Debug("Processing domain: %s", domain) + // Process the domain - fmt.Printf("Processing domain: %s\n", domain) - - // Get SPF and DMARC records - spfDmarcRecord := getSPFDMARCRecord(domain) - - // Parse SPF record - parsedSPF := parseSPFRecord(spfDmarcRecord.spfRecord, domain) - - // Scan for issues - engine := &IssueEngine{} - issues := engine.IssueScan(spfDmarcRecord, parsedSPF) - - // Check for subdomain takeover if flag is enabled - var takeoverResult *SubdomainTakeoverResult - if checkSubTakeover { - result := checkSubdomainTakeover(domain) - takeoverResult = &result - } - - // Format the output - var result string - if jsonOutput { - // Convert to JSON - jsonResult := issuestoJSON(domain, spfDmarcRecord, issues) - - // Add takeover result if available - if takeoverResult != nil { - jsonResult.TakeoverResults = takeoverResult - } - - jsonData, err := json.MarshalIndent(jsonResult, "", " ") - if err != nil { - result = fmt.Sprintf("{\"success\": false, \"message\": \"Error generating JSON: %s\"}\n", err) - } else { - result = string(jsonData) - } - - // For file output, add to all results - if outputFile != "" { - // Write to the file with appropriate comma - if domainCount > 0 { - outFile.WriteString(",\n") - } - outFile.WriteString(result) - } else { - // Print to console - fmt.Println(result) - } - } else { - // Format as text - var builder strings.Builder - builder.WriteString(fmt.Sprintf("\n--- Checking domain: %s ---\n", domain)) - builder.WriteString(fmt.Sprintf("SPF Record: %s\n", spfDmarcRecord.spfRecord)) - builder.WriteString(fmt.Sprintf("DMARC Record: %s\n\n", spfDmarcRecord.dmarcRecord)) - - if len(issues) == 0 { - builder.WriteString("No issues found. The domain is well protected against spoofing.\n") - } else { - builder.WriteString("Found the following issues:\n") - for _, issue := range issues { - builder.WriteString(fmt.Sprintf("\nCode %d: %s\n", issue.code, issue.title)) - builder.WriteString(fmt.Sprintf(" Severity: %s\n", issue.severity)) - builder.WriteString(fmt.Sprintf(" Detail: %s\n", issue.detail)) - } - } - - // Add subdomain takeover results if enabled - if takeoverResult != nil { - builder.WriteString("\n--- Subdomain Takeover Check ---\n") - if takeoverResult.Vulnerable { - builder.WriteString(fmt.Sprintf("VULNERABLE to subdomain takeover!\n")) - builder.WriteString(fmt.Sprintf("Service: %s\n", takeoverResult.Service)) - if takeoverResult.CnameRecord != "" { - builder.WriteString(fmt.Sprintf("CNAME: %s\n", takeoverResult.CnameRecord)) - } - if takeoverResult.Fingerprint != "" { - builder.WriteString(fmt.Sprintf("Matching Fingerprint: %s\n", takeoverResult.Fingerprint)) - } - if takeoverResult.ResponseBody != "" { - builder.WriteString(fmt.Sprintf("Response snippet: %s\n", takeoverResult.ResponseBody)) - } - } else { - builder.WriteString("Not vulnerable to subdomain takeover.\n") - if takeoverResult.ErrorMessage != "" { - builder.WriteString(fmt.Sprintf("Note: %s\n", takeoverResult.ErrorMessage)) - } - if takeoverResult.CnameRecord != "" { - builder.WriteString(fmt.Sprintf("CNAME: %s\n", takeoverResult.CnameRecord)) - } - } - } - - builder.WriteString("\n--- End of report ---\n") - result = builder.String() - - // If output file specified, write to it; otherwise print to console - if outputFile != "" { - outFile.WriteString(result) - } else { - fmt.Print(result) - } - } + checkDomain(domain, jsonOutput, outputFile, checkSubTakeover) domainCount++ } @@ -885,6 +905,7 @@ func processDomainFile(inputFile string, jsonOutput bool, outputFile string, che outFile.WriteString("\n]") } + Debug("Processed %d domains from %s", domainCount, inputFile) fmt.Printf("Processed %d domains from %s\n", domainCount, inputFile) if outputFile != "" { fmt.Printf("Results saved to %s\n", outputFile) @@ -919,22 +940,70 @@ func issuestoJSON(domain string, spfDmarcRecord SPFDMARCRecord, issues []IssueSc return result } +// Modified function to also display SPF record content for each domain +func DebugSPFRecord(prefix string, record *ParsedSPFRecord, domainName string, spfText string) { + if record == nil { + Debug("%sNil SPF Record", prefix) + return + } + + // Display the domain name and its SPF record at the top level + if domainName != "" && spfText != "" { + Debug("%sDomain: %s", prefix, domainName) + Debug("%sSPF Record: %s", prefix, spfText) + } + + Debug("%sA Records: %v", prefix, record.aRecord) + Debug("%sMX Records: %v", prefix, record.mxRecord) + Debug("%sExists Records: %v", prefix, record.existsRecords) + Debug("%sPTR Records: %v", prefix, record.ptrRecords) + + if len(record.includeRecords) > 0 { + Debug("%sIncludes (%d):", prefix, len(record.includeRecords)) + for i, include := range record.includeRecords { + Debug("%s [%d] Domain: %s", prefix, i, include.includeRecord) + if include.subLookup != nil { + Debug("%s [%d] SubLookup:", prefix, i) + + // Get the SPF record for this included domain + includeSPF := getSPFDMARCRecord(include.includeRecord) + DebugSPFRecord(prefix+" ", include.subLookup, include.includeRecord, includeSPF.spfRecord) + } else { + Debug("%s [%d] SubLookup: nil", prefix, i) + } + } + } else { + Debug("%sNo includes", prefix) + } +} + +// Update the checkDomain function to use the enhanced debug function func checkDomain(domain string, jsonOutput bool, outputFile string, checkSubTakeover bool) { + Debug("Checking domain: %s", domain) + // Get SPF and DMARC records spfDmarcRecord := getSPFDMARCRecord(domain) + Debug("Retrieved SPF record: %s", spfDmarcRecord.spfRecord) + Debug("Retrieved DMARC record: %s", spfDmarcRecord.dmarcRecord) // Parse SPF record - parsedSPF := parseSPFRecord(spfDmarcRecord.spfRecord, domain) + parsedSPF := parseSPFRecord(spfDmarcRecord.spfRecord, domain, "", "") + + // Use our enhanced debug function to print the parsed SPF record + Debug("Parsed SPF record structure:") + DebugSPFRecord(" ", parsedSPF, domain, spfDmarcRecord.spfRecord) // Scan for issues engine := &IssueEngine{} issues := engine.IssueScan(spfDmarcRecord, parsedSPF) + Debug("Identified issues: %+v", issues) // Check for subdomain takeover if flag is enabled var takeoverResult *SubdomainTakeoverResult if checkSubTakeover { result := checkSubdomainTakeover(domain) takeoverResult = &result + Debug("Subdomain takeover result: %+v", takeoverResult) } var output string