spoof_checker/main.go
taygun 3a846634d5 Refactor: eliminate redundant DNS lookups, duplicate code, and stale comments
- Move regex compilation to package level (was recompiled per call)
- Fetch takeover fingerprints once instead of per-domain HTTP request
- Fix defer resp.Body.Close() leak inside loop (extract fetchDomainBody)
- Store SPF text in ParsedIncludeRecord to avoid re-fetching in debug
- Extract preferIPv4() and mechanismCount() to deduplicate repeated patterns
- Remove dead allocation in parseSPFRecord
- Replace writeToFile wrapper with os.WriteFile
- Remove dead output file logic in processDomainFile
- Clean up ~25 stale/unnecessary comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:42:29 +03:00

937 lines
30 KiB
Go
Executable file

package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"regexp"
"strings"
"time"
)
var debugMode bool
// macroRegex matches SPF macro expressions like %{s}, %{d}, %{ir}
var macroRegex = regexp.MustCompile(`%{([slodipvcrt]|ir)(\d+)?r?}`)
func Debug(format string, args ...interface{}) {
if debugMode {
fmt.Printf("[DEBUG] "+format+"\n", args...)
}
}
type SPFDMARCRecord struct {
domainName string
spfRecord string
dmarcRecord string
}
type ParsedSPFRecord struct {
aRecord []string
includeRecords []ParsedIncludeRecord
mxRecord []string
existsRecords []string
ptrRecords []string
}
type ParsedIncludeRecord struct {
includeRecord string
spfText string
subLookup *ParsedSPFRecord
}
type IssueScanResult struct {
code int
title string
detail string
severity string
}
type IssueEngine struct {
rolledIncludeRecords []string
}
type JSONScanResult struct {
Domain string `json:"domain"`
SPF string `json:"spf_record"`
DMARC string `json:"dmarc_record"`
Issues []JSONIssue `json:"issues,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
TakeoverResults *SubdomainTakeoverResult `json:"subdomain_takeover,omitempty"`
}
type JSONIssue struct {
Code int `json:"code"`
Title string `json:"title"`
Detail string `json:"detail"`
Severity string `json:"severity"`
}
type TakeoverFingerprint struct {
Service string `json:"service"`
Cname []string `json:"cname"`
Fingerprint string `json:"fingerprint"`
Status string `json:"status"`
Vulnerable bool `json:"vulnerable"`
}
type SubdomainTakeoverResult struct {
Domain string `json:"domain"`
Vulnerable bool `json:"vulnerable"`
Service string `json:"service,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
CnameRecord string `json:"cname_record,omitempty"`
ErrorMessage string `json:"error,omitempty"`
}
func isNullOrWhiteSpace(s string) bool {
return len(strings.TrimSpace(s)) == 0
}
func preferIPv4(ips []net.IP) string {
for _, ip := range ips {
if ip.To4() != nil {
return ip.String()
}
}
return ips[0].String()
}
func (r *ParsedSPFRecord) mechanismCount() int {
return len(r.aRecord) + len(r.mxRecord) + len(r.existsRecords) + len(r.ptrRecords)
}
func getSPFDMARCRecord(domain string) SPFDMARCRecord {
var record SPFDMARCRecord
record.domainName = domain
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: 3 * time.Second,
}
return d.DialContext(ctx, network, address)
},
}
spfChan := make(chan string, 1)
go func() {
txtRecords, err := resolver.LookupTXT(ctx, domain)
if err == nil {
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfChan <- txt
return
}
}
}
spfChan <- ""
}()
dmarcChan := make(chan string, 1)
go func() {
txtRecords, err := resolver.LookupTXT(ctx, "_dmarc."+domain)
if err == nil {
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcChan <- txt
return
}
}
}
dmarcChan <- ""
}()
select {
case record.spfRecord = <-spfChan:
case <-ctx.Done():
// Timeout occurred
record.spfRecord = ""
}
select {
case record.dmarcRecord = <-dmarcChan:
case <-ctx.Done():
// Timeout occurred
record.dmarcRecord = ""
}
return record
}
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, ".")
}
func expandSPFMacro(input string, domain string, sender string, ip string) string {
reverseIPValue := ""
if ip != "" {
reverseIPValue = reverseIP(ip)
}
localPart := ""
domainPart := ""
if strings.Contains(sender, "@") {
parts := strings.SplitN(sender, "@", 2)
localPart = parts[0]
domainPart = parts[1]
}
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()),
}
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
})
}
func resolveIPFromSPF(spfRecord string, domain string) string {
ips, err := net.LookupIP(domain)
if err == nil && len(ips) > 0 {
result := preferIPv4(ips)
Debug("Resolved IP for %s: %s", domain, result)
return result
}
// Return a loopback IP to prevent empty macros in SPF expansion
Debug("Could not resolve any IP for domain %s", domain)
return "127.0.0.1"
}
func parseSPFRecord(spfRecord string, domainName string, sender string, ip string) *ParsedSPFRecord {
if ip == "" {
ip = resolveIPFromSPF(spfRecord, domainName)
}
if isNullOrWhiteSpace(spfRecord) {
return &ParsedSPFRecord{}
}
visitedDomains := make(map[string]bool)
visitedDomains[domainName] = true
Debug("Starting SPF parsing for domain: %s", domainName)
return parseSPFRecordWithDepth(spfRecord, domainName, sender, ip, visitedDomains, 0)
}
func parseSPFRecordWithDepth(spfRecord string, domainName string, sender string, ip string, visitedDomains map[string]bool, depth int) *ParsedSPFRecord {
maxDepth := 10
if depth > maxDepth {
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),
mxRecord: make([]string, 0),
existsRecords: make([]string, 0),
ptrRecords: make([]string, 0),
}
mechanisms := strings.Split(spfRecord, " ")
for _, mechanism := range mechanisms {
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
}
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,
spfText: includeSPF.spfRecord,
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)
if visitedDomains[redirectDomain] {
Debug("Loop detected for redirect: %s", redirectDomain)
continue
}
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
}
func subDomainSPFNotExists(domain string) bool {
randomSub := "random123456." + domain
txtRecords, err := net.LookupTXT(randomSub)
if err != nil {
return true // Error means no wildcard record
}
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
return false // Found SPF for subdomain
}
}
return true
}
func (engine *IssueEngine) IssueScan(spfDMARCRecord SPFDMARCRecord, extendedSPF *ParsedSPFRecord) []IssueScanResult {
issueResults := make([]IssueScanResult, 0)
spfIssues := engine.SPFIssueScan(spfDMARCRecord.spfRecord, spfDMARCRecord.domainName, extendedSPF)
issueResults = append(issueResults, spfIssues...)
if len(issueResults) >= 1 {
if issueResults[0].code != 0 && issueResults[0].code != 11 {
issueResults = engine.DMARCIssueScan(spfDMARCRecord.dmarcRecord, spfDMARCRecord.domainName, issueResults)
}
} else {
issueResults = engine.DMARCIssueScan(spfDMARCRecord.dmarcRecord, spfDMARCRecord.domainName, issueResults)
}
return issueResults
}
func (engine *IssueEngine) SPFIssueScan(spfRecord string, domainName string, parsedSPF *ParsedSPFRecord) []IssueScanResult {
spfIssues := make([]IssueScanResult, 0)
if subDomainSPFNotExists(domainName) {
spfIssues = append(spfIssues, engine.issueDescriptors(6, domainName))
}
if isNullOrWhiteSpace(spfRecord) {
_, err := net.LookupIP(domainName)
if err != nil {
// Check if it's a CNAME
_, err = net.LookupCNAME(domainName)
if err != nil {
spfIssues = append(spfIssues, engine.issueDescriptors(0, domainName))
} else {
spfIssues = append(spfIssues, engine.issueDescriptors(1, domainName))
}
} else {
spfIssues = append(spfIssues, engine.issueDescriptors(1, domainName))
}
} else {
// Check for redirect and update SPF record if needed
if strings.Contains(spfRecord, "redirect=") {
splitSPF := strings.Split(spfRecord, " ")
for _, part := range splitSPF {
if strings.HasPrefix(part, "redirect=") {
redirectDomain := strings.TrimPrefix(part, "redirect=")
redirectRecord := getSPFDMARCRecord(redirectDomain)
spfRecord = redirectRecord.spfRecord
break
}
}
}
if strings.Contains(spfRecord, "+all") {
spfIssues = append(spfIssues, engine.issueDescriptors(3, domainName))
} else if strings.Contains(spfRecord, "~all") {
spfIssues = append(spfIssues, engine.issueDescriptors(4, domainName))
} else if !strings.Contains(spfRecord, "-all") || strings.Contains(spfRecord, "?all") {
spfIssues = append(spfIssues, engine.issueDescriptors(2, domainName))
}
if engine.includeRollUp(parsedSPF) > 10 {
spfIssues = append(spfIssues, engine.issueDescriptors(5, domainName))
}
// Check for loops
seenIncludes := make(map[string]bool)
for _, include := range engine.rolledIncludeRecords {
if seenIncludes[include] {
spfIssues = append(spfIssues, engine.issueDescriptors(12, domainName))
break
}
seenIncludes[include] = true
}
}
return spfIssues
}
func (engine *IssueEngine) DMARCIssueScan(dmarcRecord string, domainName string, spfIssues []IssueScanResult) []IssueScanResult {
dmarcIssues := make([]IssueScanResult, 0)
updatedIssues := make([]IssueScanResult, 0)
if isNullOrWhiteSpace(dmarcRecord) {
dmarcIssues = append(dmarcIssues, engine.issueDescriptors(7, domainName))
} else {
if strings.Contains(dmarcRecord, "; p=none") || strings.Contains(dmarcRecord, ";p=none") {
dmarcIssues = append(dmarcIssues, engine.issueDescriptors(8, domainName))
}
if strings.Contains(dmarcRecord, "sp=none") {
dmarcIssues = append(dmarcIssues, engine.issueDescriptors(9, domainName))
}
if strings.Contains(dmarcRecord, "pct=") && !strings.Contains(dmarcRecord, "pct=100") {
dmarcIssues = append(dmarcIssues, engine.issueDescriptors(10, domainName))
}
}
hasDMARCIssue7 := false
hasDMARCIssue8 := false
hasDMARCIssue10 := false
for _, issue := range dmarcIssues {
if issue.code == 7 {
hasDMARCIssue7 = true
} else if issue.code == 8 {
hasDMARCIssue8 = true
} else if issue.code == 10 {
hasDMARCIssue10 = true
}
}
if !hasDMARCIssue7 && !hasDMARCIssue8 && !hasDMARCIssue10 {
for _, spfIssue := range spfIssues {
if spfIssue.code != 3 {
updatedIssues = append(updatedIssues, engine.issueDescriptorUpdate(spfIssue))
} else {
updatedIssues = append(updatedIssues, spfIssue)
}
}
} else {
updatedIssues = append(updatedIssues, spfIssues...)
}
updatedIssues = append(updatedIssues, dmarcIssues...)
return updatedIssues
}
func (engine *IssueEngine) includeRollUp(parsedSPF *ParsedSPFRecord) int {
if parsedSPF == nil {
return 0
}
engine.rolledIncludeRecords = make([]string, 0)
visited := make(map[string]bool)
lookupCount := parsedSPF.mechanismCount()
for _, includeRecord := range parsedSPF.includeRecords {
engine.rolledIncludeRecords = append(engine.rolledIncludeRecords, includeRecord.includeRecord)
lookupCount++
if includeRecord.subLookup != nil {
lookupCount += engine.countLookupsNonRecursive(includeRecord.subLookup, visited)
if lookupCount > 10 {
return lookupCount
}
}
}
return lookupCount
}
func (engine *IssueEngine) countLookupsNonRecursive(spf *ParsedSPFRecord, visited map[string]bool) int {
if spf == nil {
return 0
}
lookupCount := spf.mechanismCount()
stack := []*ParsedSPFRecord{spf}
processedIncludes := make(map[string]bool)
for len(stack) > 0 {
if lookupCount > 10 {
return lookupCount
}
current := stack[len(stack)-1]
stack = stack[:len(stack)-1]
for _, include := range current.includeRecords {
if processedIncludes[include.includeRecord] {
continue
}
processedIncludes[include.includeRecord] = true
engine.rolledIncludeRecords = append(engine.rolledIncludeRecords, include.includeRecord)
lookupCount++
if include.subLookup != nil {
stack = append(stack, include.subLookup)
lookupCount += include.subLookup.mechanismCount()
}
}
}
return lookupCount
}
func (engine *IssueEngine) issueDescriptorUpdate(issueResult IssueScanResult) IssueScanResult {
issueResult.detail = "This issue has been mitigated through the DMARC policy 'p' qualifier being set to 'Quarantine' or 'Reject'."
issueResult.severity = "Mitigated"
return issueResult
}
func (engine *IssueEngine) issueDescriptors(code int, domain string) IssueScanResult {
issueResult := IssueScanResult{}
switch code {
case 0:
issueResult.code = 0
issueResult.title = "Non-existent domain"
issueResult.detail = "The DNS resolver raised an NXDomain error for " + domain + ". Mail receivers will be unable to resolve a DNS response for your domain and will almost certainly flag any mail as spam."
issueResult.severity = "Low"
case 1:
issueResult.code = 1
issueResult.title = "No SPF record exists"
issueResult.detail = "There is no SPF DNS record for " + domain + ". Mail receivers have no mechanism to determine what your authorised mail servers are. Mail receivers will pass authentication checks with a \"None\" result, indicating no check could be performed. Spoofed emails are likely to be accepted."
issueResult.severity = "High"
case 2:
issueResult.code = 2
issueResult.title = "SPF \"all\" mechanism is missing or set to \"?all\""
issueResult.detail = "The \"all\" mechanism at the end of the end of an SPF record tells receivers how to treat unauthorised (i.e. spoofed) emails - if the mechanism is missing or set to \"?all\", authentication checks will always return a \"Neutral\" result which many receivers interpret to accept all mail from " + domain + " (including spoofed emails)."
issueResult.severity = "High"
case 3:
issueResult.code = 3
issueResult.title = "SPF \"+all\" mechanism set"
issueResult.detail = "The \"all\" mechanism at the end of the end of an SPF record tells receivers how to treat unauthorised (i.e. spoofed) emails - the \"+all\" setting tells receivers to pass/accept all mail from " + domain + " (including spoofed emails)."
issueResult.severity = "Very High"
case 4:
issueResult.code = 4
issueResult.title = "SPF \"~all\" (SoftFail) mechanism set"
issueResult.detail = "The \"all\" mechanism at the end of the end of an SPF record tells receivers how to treat unauthorised (i.e. spoofed) emails - the \"~all\" setting tells receivers to 'SoftFail' (i.e. quarantine) emails that fail SPF authentication. In practice however, many email filters only slightly raise the total spam score and accept 'SoftFailed' (i.e. spoofed) emails."
issueResult.severity = "Medium"
case 5:
issueResult.code = 5
issueResult.title = "SPF has too many lookups for receiver validation"
issueResult.detail = "The SPF record requires more than 10 DNS lookups for the validation process. The RFC states that maximum 10 lookups are allowed. As a result, recipients may throw a PermError instead of proceeding with SPF validation. Recipients treat these errors differently than a hard or soft SPF fail, but some will continue processing the mail (i.e. accept spoofed emails)."
issueResult.severity = "Medium"
case 6:
issueResult.code = 6
issueResult.title = "No SPF sub-domain record exists"
issueResult.detail = "The SPF sub-domain policy is a catch-all mechanism used to prevent threat actors from maliciously spoofing sub-domains from which an explicit SPF record hasn't been set. This is typically represented through a DNS entry similar to \"* IN TXT v=spf1 -all\", effectively telling recipients to block mail if an explicit SPF entry for the sub-domain hasn't been set."
issueResult.severity = "Medium"
case 7:
issueResult.code = 7
issueResult.title = "No DMARC record exists"
issueResult.detail = "There is no DMARC DNS Record set for the domain. Spoofed emails utilising an attack technique known as SPF-bypass are likely to be accepted."
issueResult.severity = "High"
case 8:
issueResult.code = 8
issueResult.title = "Insecure DMARC policy 'p' qualifier"
issueResult.detail = "The DMARC policy 'p' qualifier is \"none\". If the DMARC policy is neither \"reject\" nor \"quarantine\", spoofed emails utilising an attack technique known as SPF-bypass are likely to be accepted."
issueResult.severity = "High"
case 9:
issueResult.code = 9
issueResult.title = "Insecure DMARC sub-domain 'p' qualifier"
issueResult.detail = "The DMARC policy 'p' qualifier for sub-domains is set to \"none\". If the DMARC policy is neither \"reject\" nor \"quarantine\", spoofed emails from any " + domain + " sub-domain utilising an attack technique known as SPF-bypass are likely to be accepted."
issueResult.severity = "High"
case 10:
issueResult.code = 10
issueResult.title = "Partial DMARC coverage"
issueResult.detail = "The DMARC \"pct\" value is set to less than '100' (i.e. 100%), meaning the DMARC policy will only be applied to a percentage of incoming mail. A threat actor can continously deliver spoofed emails (via SPF-bypass) until the DMARC policy isn't applied and mail is accepted."
issueResult.severity = "Medium"
case 11:
issueResult.code = 11
issueResult.title = "DNS Timeout during Scan"
issueResult.detail = "There was a DNS timeout when querying " + domain + ". This will result in an SPF temperror and any mail will almost certainly be flagged as spam by mail receivers"
issueResult.severity = "Low"
case 12:
issueResult.code = 12
issueResult.title = "Trivial SPF recurse loop"
issueResult.detail = "The SPF record is configured whereby an infinite lookup loop exists in the validation chain for " + domain + ". This will likely result in an SPF PermError. Recipients will treat these errors differently than a hard or soft SPF fail, but many will continue processing the mail (i.e. accept spoofed emails)."
issueResult.severity = "Medium"
}
return issueResult
}
func getFingerprints() ([]TakeoverFingerprint, error) {
fingerprintsURL := "https://raw.githubusercontent.com/EdOverflow/can-i-take-over-xyz/master/fingerprints.json"
resp, err := http.Get(fingerprintsURL)
if err != nil {
return nil, fmt.Errorf("failed to download fingerprints: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read fingerprints data: %v", err)
}
var fingerprints []TakeoverFingerprint
if err := json.Unmarshal(body, &fingerprints); err != nil {
return nil, fmt.Errorf("failed to parse fingerprints JSON: %v", err)
}
return fingerprints, nil
}
func checkSubdomainTakeover(domain string, fingerprints []TakeoverFingerprint) SubdomainTakeoverResult {
result := SubdomainTakeoverResult{
Domain: domain,
Vulnerable: false,
}
cname, err := net.LookupCNAME(domain)
if err != nil {
result.ErrorMessage = fmt.Sprintf("Failed to lookup CNAME: %v", err)
return result
}
cname = strings.TrimSuffix(cname, ".")
result.CnameRecord = cname
for _, fp := range fingerprints {
for _, cnamePattern := range fp.Cname {
if strings.Contains(cname, cnamePattern) {
if fp.Fingerprint != "" {
responseText := fetchDomainBody(domain)
if responseText != "" && strings.Contains(responseText, fp.Fingerprint) && fp.Vulnerable {
result.Vulnerable = true
result.Service = fp.Service
result.Fingerprint = fp.Fingerprint
if len(responseText) > 200 {
result.ResponseBody = responseText[:200] + "..."
} else {
result.ResponseBody = responseText
}
return result
}
} else if fp.Vulnerable {
result.Vulnerable = true
result.Service = fp.Service
return result
}
break
}
}
}
return result
}
func fetchDomainBody(domain string) string {
resp, err := http.Get("http://" + domain)
if err != nil {
resp, err = http.Get("https://" + domain)
if err != nil {
return ""
}
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return ""
}
return string(body)
}
func main() {
var domain string
var interactive bool
var jsonOutput bool
var outputFile string
var inputFile string
var checkSubTakeover bool
flag.BoolVar(&debugMode, "debug", false, "Enable debug mode")
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")
flag.StringVar(&outputFile, "output", "", "Save results to specified file")
flag.StringVar(&inputFile, "input", "", "Read domains from specified file (one domain per line)")
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)
var fingerprints []TakeoverFingerprint
if checkSubTakeover {
var err error
fingerprints, err = getFingerprints()
if err != nil {
fmt.Printf("Warning: could not load takeover fingerprints: %v\n", err)
}
}
if inputFile != "" {
processDomainFile(inputFile, jsonOutput, outputFile, fingerprints)
} else if interactive {
for {
fmt.Print("Enter domain to check (or 'exit' to quit): ")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
domain = scanner.Text()
if strings.ToLower(domain) == "exit" {
break
}
checkDomain(domain, jsonOutput, outputFile, fingerprints)
}
} else if domain != "" {
checkDomain(domain, jsonOutput, outputFile, fingerprints)
} else {
fmt.Println("Please specify a domain with -domain, use -input to read from a file, or use -interactive")
flag.Usage()
}
}
func processDomainFile(inputFile string, jsonOutput bool, outputFile string, fingerprints []TakeoverFingerprint) {
Debug("Processing input file: %s", inputFile)
file, err := os.Open(inputFile)
if err != nil {
fmt.Printf("Error opening file %s: %v\n", inputFile, err)
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
domainCount := 0
for scanner.Scan() {
domain := strings.TrimSpace(scanner.Text())
if domain == "" || strings.HasPrefix(domain, "#") {
continue
}
Debug("Processing domain: %s", domain)
checkDomain(domain, jsonOutput, outputFile, fingerprints)
domainCount++
}
if err := scanner.Err(); err != nil {
fmt.Printf("Error reading file: %v\n", err)
}
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)
}
}
func issuestoJSON(domain string, spfDmarcRecord SPFDMARCRecord, issues []IssueScanResult) JSONScanResult {
result := JSONScanResult{
Domain: domain,
SPF: spfDmarcRecord.spfRecord,
DMARC: spfDmarcRecord.dmarcRecord,
Issues: make([]JSONIssue, 0),
Success: true,
}
if len(issues) == 0 {
result.Message = "No issues found. The domain is well protected against spoofing."
} else {
for _, issue := range issues {
jsonIssue := JSONIssue{
Code: issue.code,
Title: issue.title,
Detail: issue.detail,
Severity: issue.severity,
}
result.Issues = append(result.Issues, jsonIssue)
}
}
return result
}
func DebugSPFRecord(prefix string, record *ParsedSPFRecord, domainName string, spfText string) {
if record == nil {
Debug("%sNil SPF Record", prefix)
return
}
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)
DebugSPFRecord(prefix+" ", include.subLookup, include.includeRecord, include.spfText)
} else {
Debug("%s [%d] SubLookup: nil", prefix, i)
}
}
} else {
Debug("%sNo includes", prefix)
}
}
func checkDomain(domain string, jsonOutput bool, outputFile string, fingerprints []TakeoverFingerprint) {
Debug("Checking domain: %s", domain)
spfDmarcRecord := getSPFDMARCRecord(domain)
Debug("Retrieved SPF record: %s", spfDmarcRecord.spfRecord)
Debug("Retrieved DMARC record: %s", spfDmarcRecord.dmarcRecord)
parsedSPF := parseSPFRecord(spfDmarcRecord.spfRecord, domain, "", "")
Debug("Parsed SPF record structure:")
DebugSPFRecord(" ", parsedSPF, domain, spfDmarcRecord.spfRecord)
engine := &IssueEngine{}
issues := engine.IssueScan(spfDmarcRecord, parsedSPF)
Debug("Identified issues: %+v", issues)
var takeoverResult *SubdomainTakeoverResult
if fingerprints != nil {
result := checkSubdomainTakeover(domain, fingerprints)
takeoverResult = &result
Debug("Subdomain takeover result: %+v", takeoverResult)
}
var output string
var fileOutput string
if jsonOutput {
jsonResult := issuestoJSON(domain, spfDmarcRecord, issues)
if takeoverResult != nil {
jsonResult.TakeoverResults = takeoverResult
}
jsonData, err := json.MarshalIndent(jsonResult, "", " ")
if err != nil {
output = fmt.Sprintf("{\"success\": false, \"message\": \"Error generating JSON: %s\"}\n", err)
fileOutput = output
} else {
output = string(jsonData)
if outputFile != "" {
fileOutput = "[\n" + output + "\n]"
} else {
fileOutput = output
}
}
} else {
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))
}
}
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")
output = builder.String()
fileOutput = output
}
fmt.Print(output)
if outputFile != "" {
err := os.WriteFile(outputFile, []byte(fileOutput), 0644)
if err != nil {
fmt.Printf("Error writing to file %s: %v\n", outputFile, err)
} else {
fmt.Printf("Results saved to %s\n", outputFile)
}
}
}