mirror of
https://github.com/taygun08/spoof_checker.git
synced 2026-10-21 20:00:48 +00:00
Add files via upload
This commit is contained in:
parent
b4f50c34fd
commit
cfe997f221
2 changed files with 997 additions and 0 deletions
164
README.md
Normal file
164
README.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# SPF/DMARC Spoofing Vulnerability Checker
|
||||
|
||||
A command-line tool written in Go that checks domains for email spoofing vulnerabilities by analyzing their SPF and DMARC DNS records.
|
||||
|
||||
## Features
|
||||
|
||||
- Analyzes SPF (Sender Policy Framework) records for common misconfigurations
|
||||
- Evaluates DMARC (Domain-based Message Authentication, Reporting & Conformance) policies
|
||||
- Detects potential spoofing vulnerabilities like:
|
||||
- Missing or improperly configured SPF/DMARC records
|
||||
- Overly permissive SPF policies (e.g., "+all" or "?all")
|
||||
- Insecure DMARC policy qualifiers
|
||||
- SPF record loops and excessive DNS lookups
|
||||
- Multiple output formats (text and JSON)
|
||||
- Process individual domains or bulk check from a file
|
||||
- Interactive mode for checking domains one by one
|
||||
- Save results to an output file
|
||||
- Concurrent processing for faster bulk scanning
|
||||
|
||||
## Installation
|
||||
|
||||
Assuming you have Go installed, you can build the program with:
|
||||
|
||||
```bash
|
||||
go build spoof_check.go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Check a single domain
|
||||
|
||||
```bash
|
||||
./spoof_check -domain example.com
|
||||
```
|
||||
|
||||
### Process multiple domains from a file
|
||||
|
||||
Create a text file with one domain per line. Lines starting with `#` will be treated as comments and skipped.
|
||||
|
||||
```bash
|
||||
./spoof_check -input domains.txt
|
||||
```
|
||||
|
||||
### Interactive mode
|
||||
|
||||
```bash
|
||||
./spoof_check -interactive
|
||||
```
|
||||
|
||||
### Output options
|
||||
|
||||
#### JSON output
|
||||
|
||||
```bash
|
||||
./spoof_check -domain example.com -json
|
||||
```
|
||||
|
||||
#### Save results to a file
|
||||
|
||||
```bash
|
||||
./spoof_check -domain example.com -output results.txt
|
||||
```
|
||||
|
||||
#### JSON output saved to a file
|
||||
|
||||
```bash
|
||||
./spoof_check -domain example.com -json -output results.json
|
||||
```
|
||||
|
||||
#### Bulk processing with JSON output
|
||||
|
||||
```bash
|
||||
./spoof_check -input domains.txt -json -output results.json
|
||||
```
|
||||
|
||||
## Command-Line Options
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| `-domain` | Specify a single domain to check |
|
||||
| `-input` | Specify a file containing a list of domains to check |
|
||||
| `-output` | Save results to the specified file |
|
||||
| `-json` | Output results in JSON format |
|
||||
| `-interactive` | Run in interactive mode |
|
||||
|
||||
## Issue Codes
|
||||
|
||||
The tool identifies several types of issues with the following codes:
|
||||
|
||||
| Code | Title | Severity |
|
||||
| ---- | ----- | -------- |
|
||||
| 0 | Non-existent domain | Low |
|
||||
| 1 | No SPF record exists | High |
|
||||
| 2 | SPF "all" mechanism is missing or set to "?all" | High |
|
||||
| 3 | SPF "+all" mechanism set | Very High |
|
||||
| 4 | SPF "~all" (SoftFail) mechanism set | Medium |
|
||||
| 5 | SPF has too many lookups for receiver validation | Medium |
|
||||
| 6 | No SPF sub-domain record exists | Medium |
|
||||
| 7 | No DMARC record exists | High |
|
||||
| 8 | Insecure DMARC policy 'p' qualifier | High |
|
||||
| 9 | Insecure DMARC sub-domain 'p' qualifier | High |
|
||||
| 10 | Partial DMARC coverage | Medium |
|
||||
| 11 | DNS Timeout during Scan | Low |
|
||||
| 12 | Trivial SPF recurse loop | Medium |
|
||||
|
||||
## Example Output
|
||||
|
||||
### Text format:
|
||||
|
||||
```
|
||||
--- Checking domain: example.com ---
|
||||
SPF Record: v=spf1 include:_spf.example.com ~all
|
||||
DMARC Record: v=DMARC1; p=none; rua=mailto:dmarc@example.com
|
||||
|
||||
Found the following issues:
|
||||
|
||||
Code 4: SPF "~all" (SoftFail) mechanism set
|
||||
Severity: Medium
|
||||
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.
|
||||
|
||||
Code 8: Insecure DMARC policy 'p' qualifier
|
||||
Severity: High
|
||||
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.
|
||||
|
||||
--- End of report ---
|
||||
```
|
||||
|
||||
### JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "example.com",
|
||||
"spf_record": "v=spf1 include:_spf.example.com ~all",
|
||||
"dmarc_record": "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
||||
"issues": [
|
||||
{
|
||||
"code": 4,
|
||||
"title": "SPF \"~all\" (SoftFail) mechanism set",
|
||||
"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.",
|
||||
"severity": "Medium"
|
||||
},
|
||||
{
|
||||
"code": 8,
|
||||
"title": "Insecure DMARC policy 'p' qualifier",
|
||||
"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.",
|
||||
"severity": "High"
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
Based on the issues detected, you should consider:
|
||||
|
||||
1. Setting SPF records to end with `-all` (hard fail) instead of `~all` or `?all`
|
||||
2. Ensuring DMARC policy 'p' qualifier is set to 'quarantine' or 'reject'
|
||||
3. Configuring wildcard SPF records for sub-domains
|
||||
4. Setting DMARC percentage to 100% for full coverage
|
||||
|
||||
## License
|
||||
|
||||
This tool is provided as-is without any warranty. Use at your own risk.
|
||||
833
spoof_check.go
Normal file
833
spoof_check.go
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context" // New import
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings" // New import
|
||||
"time" // New import
|
||||
)
|
||||
|
||||
// SPFDMARCRecord contains both SPF and DMARC records for a domain
|
||||
type SPFDMARCRecord struct {
|
||||
domainName string
|
||||
spfRecord string
|
||||
dmarcRecord string
|
||||
}
|
||||
|
||||
// ParsedSPFRecord contains the parsed elements of an SPF record
|
||||
type ParsedSPFRecord struct {
|
||||
aRecord []string
|
||||
includeRecords []ParsedIncludeRecord
|
||||
mxRecord []string
|
||||
existsRecords []string
|
||||
ptrRecords []string
|
||||
}
|
||||
|
||||
// ParsedIncludeRecord represents an include statement in SPF
|
||||
type ParsedIncludeRecord struct {
|
||||
includeRecord string
|
||||
subLookup *ParsedSPFRecord
|
||||
}
|
||||
|
||||
// IssueScanResult represents a detected issue during scanning
|
||||
type IssueScanResult struct {
|
||||
code int
|
||||
title string
|
||||
detail string
|
||||
severity string
|
||||
}
|
||||
|
||||
// IssueEngine handles the scanning logic for SPF and DMARC records
|
||||
type IssueEngine struct {
|
||||
rolledIncludeRecords []string
|
||||
}
|
||||
|
||||
// JSONScanResult represents the JSON structure for scan results
|
||||
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"`
|
||||
}
|
||||
|
||||
// JSONIssue represents a single issue in JSON format
|
||||
type JSONIssue struct {
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
// Function to check if a string is empty
|
||||
func isNullOrWhiteSpace(s string) bool {
|
||||
return len(strings.TrimSpace(s)) == 0
|
||||
}
|
||||
|
||||
// Get SPF and DMARC records for a domain
|
||||
func getSPFDMARCRecord(domain string) SPFDMARCRecord {
|
||||
var record SPFDMARCRecord
|
||||
record.domainName = domain
|
||||
|
||||
// Setup timeout context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use DNS resolver with custom timeout
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
// Get SPF record with timeout
|
||||
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 <- ""
|
||||
}()
|
||||
|
||||
// Get DMARC record with timeout
|
||||
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 <- ""
|
||||
}()
|
||||
|
||||
// Wait for results with timeout
|
||||
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
|
||||
}
|
||||
|
||||
// Parse SPF record to extract all components
|
||||
func parseSPFRecord(spfRecord string, domainName string) *ParsedSPFRecord {
|
||||
parsedRecord := &ParsedSPFRecord{
|
||||
aRecord: make([]string, 0),
|
||||
includeRecords: make([]ParsedIncludeRecord, 0),
|
||||
mxRecord: make([]string, 0),
|
||||
existsRecords: make([]string, 0),
|
||||
ptrRecords: make([]string, 0),
|
||||
}
|
||||
|
||||
if isNullOrWhiteSpace(spfRecord) {
|
||||
return parsedRecord
|
||||
}
|
||||
|
||||
// Track domains to prevent recursion loops
|
||||
visitedDomains := make(map[string]bool)
|
||||
visitedDomains[domainName] = true
|
||||
|
||||
return parseSPFRecordWithDepth(spfRecord, domainName, visitedDomains, 0)
|
||||
}
|
||||
|
||||
// Helper function with depth tracking to prevent stack overflow
|
||||
func parseSPFRecordWithDepth(spfRecord string, domainName 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),
|
||||
}
|
||||
}
|
||||
|
||||
parsedRecord := &ParsedSPFRecord{
|
||||
aRecord: make([]string, 0),
|
||||
includeRecords: make([]ParsedIncludeRecord, 0),
|
||||
mxRecord: make([]string, 0),
|
||||
existsRecords: make([]string, 0),
|
||||
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,
|
||||
}
|
||||
|
||||
// Skip already visited domains to prevent loops
|
||||
if !visitedDomains[includeDomain] {
|
||||
visitedDomains[includeDomain] = true
|
||||
|
||||
// Get the SPF record for the included domain
|
||||
includedSPF := getSPFDMARCRecord(includeDomain)
|
||||
if !isNullOrWhiteSpace(includedSPF.spfRecord) {
|
||||
includeRecord.subLookup = parseSPFRecordWithDepth(includedSPF.spfRecord, includeDomain, visitedDomains, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedRecord
|
||||
}
|
||||
|
||||
// Check if subdomain SPF exists
|
||||
func subDomainSPFNotExists(domain string) bool {
|
||||
// Try to lookup a fake subdomain to see if wildcard SPF exists
|
||||
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
|
||||
}
|
||||
|
||||
// Check for issues in SPF and DMARC records
|
||||
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
|
||||
}
|
||||
|
||||
// Check for SPF-related issues
|
||||
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
|
||||
}
|
||||
|
||||
// Check for DMARC-related issues
|
||||
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
|
||||
}
|
||||
|
||||
// Count total DNS lookups needed for SPF validation
|
||||
func (engine *IssueEngine) includeRollUp(parsedSPF *ParsedSPFRecord) int {
|
||||
if parsedSPF == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Reset the include record tracking
|
||||
engine.rolledIncludeRecords = make([]string, 0)
|
||||
|
||||
// Track visited domains to prevent infinite loops
|
||||
visited := make(map[string]bool)
|
||||
|
||||
// Count lookups with safety limits
|
||||
lookupCount := 0
|
||||
|
||||
// Count all mechanisms in the base record
|
||||
lookupCount += len(parsedSPF.aRecord)
|
||||
lookupCount += len(parsedSPF.mxRecord)
|
||||
lookupCount += len(parsedSPF.existsRecords)
|
||||
lookupCount += len(parsedSPF.ptrRecords)
|
||||
|
||||
// Process all includes with a safety limit
|
||||
for _, includeRecord := range parsedSPF.includeRecords {
|
||||
engine.rolledIncludeRecords = append(engine.rolledIncludeRecords, includeRecord.includeRecord)
|
||||
lookupCount++ // Count the include itself as a lookup
|
||||
|
||||
// Process the include safely
|
||||
if includeRecord.subLookup != nil {
|
||||
subCount := engine.countLookupsNonRecursive(includeRecord.subLookup, visited)
|
||||
lookupCount += subCount
|
||||
|
||||
// Early exit if we're already over the limit
|
||||
if lookupCount > 10 {
|
||||
return lookupCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookupCount
|
||||
}
|
||||
|
||||
// Non-recursive version of countLookupsRecursive to prevent stack overflow
|
||||
func (engine *IssueEngine) countLookupsNonRecursive(spf *ParsedSPFRecord, visited map[string]bool) int {
|
||||
if spf == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Count basic lookups
|
||||
lookupCount := len(spf.aRecord) + len(spf.mxRecord) + len(spf.existsRecords) + len(spf.ptrRecords)
|
||||
|
||||
// Use a stack to simulate recursion
|
||||
type StackItem struct {
|
||||
spf *ParsedSPFRecord
|
||||
}
|
||||
|
||||
stack := []StackItem{{spf: spf}}
|
||||
processedIncludes := make(map[string]bool)
|
||||
|
||||
for len(stack) > 0 {
|
||||
// Check if we're already over limit for early exit
|
||||
if lookupCount > 10 {
|
||||
return lookupCount
|
||||
}
|
||||
|
||||
// Pop from stack
|
||||
current := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
|
||||
// Process all includes in this record
|
||||
for _, include := range current.spf.includeRecords {
|
||||
// Skip if already processed this include to prevent loops
|
||||
if processedIncludes[include.includeRecord] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as processed and count it
|
||||
processedIncludes[include.includeRecord] = true
|
||||
engine.rolledIncludeRecords = append(engine.rolledIncludeRecords, include.includeRecord)
|
||||
lookupCount++ // Count the include itself
|
||||
|
||||
// Add nested includes to stack if they exist
|
||||
if include.subLookup != nil {
|
||||
stack = append(stack, StackItem{spf: include.subLookup})
|
||||
|
||||
// Count direct mechanisms in this include
|
||||
lookupCount += len(include.subLookup.aRecord)
|
||||
lookupCount += len(include.subLookup.mxRecord)
|
||||
lookupCount += len(include.subLookup.existsRecords)
|
||||
lookupCount += len(include.subLookup.ptrRecords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookupCount
|
||||
}
|
||||
|
||||
// Update issue status to mitigated when appropriate DMARC policies are in place
|
||||
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
|
||||
}
|
||||
|
||||
// Create issue descriptors based on the issue code
|
||||
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 main() {
|
||||
// Parse command line arguments
|
||||
var domain string
|
||||
var interactive bool
|
||||
var jsonOutput bool
|
||||
var outputFile string
|
||||
var inputFile string
|
||||
|
||||
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.Parse()
|
||||
|
||||
// Process domains from input file if specified
|
||||
if inputFile != "" {
|
||||
processDomainFile(inputFile, jsonOutput, outputFile)
|
||||
} 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)
|
||||
}
|
||||
} else if domain != "" {
|
||||
checkDomain(domain, jsonOutput, outputFile)
|
||||
} else {
|
||||
fmt.Println("Please specify a domain with -domain, use -input to read from a file, or use -interactive")
|
||||
flag.Usage()
|
||||
}
|
||||
}
|
||||
|
||||
// Process a file containing a list of domains
|
||||
func processDomainFile(inputFile string, jsonOutput bool, outputFile string) {
|
||||
// Read the file
|
||||
file, err := os.Open(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening file %s: %v\n", inputFile, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create a scanner to read the file line by line
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// Open output file once if specified
|
||||
var outFile *os.File
|
||||
if outputFile != "" {
|
||||
outFile, err = os.Create(outputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating output file %s: %v\n", outputFile, err)
|
||||
return
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// If JSON output, start the JSON array
|
||||
if jsonOutput {
|
||||
outFile.WriteString("[\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Process each domain
|
||||
domainCount := 0
|
||||
for scanner.Scan() {
|
||||
domain := strings.TrimSpace(scanner.Text())
|
||||
if domain == "" || strings.HasPrefix(domain, "#") {
|
||||
continue // Skip empty lines and comments
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Format the output
|
||||
var result string
|
||||
if jsonOutput {
|
||||
// Convert to JSON
|
||||
jsonResult := issuestoJSON(domain, spfDmarcRecord, issues)
|
||||
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))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
domainCount++
|
||||
}
|
||||
|
||||
// Check for scanner errors
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Printf("Error reading file: %v\n", err)
|
||||
}
|
||||
|
||||
// If JSON output and file output, close the JSON array
|
||||
if outputFile != "" && jsonOutput {
|
||||
outFile.WriteString("\n]")
|
||||
}
|
||||
|
||||
fmt.Printf("Processed %d domains from %s\n", domainCount, inputFile)
|
||||
if outputFile != "" {
|
||||
fmt.Printf("Results saved to %s\n", outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert scan results to JSON format
|
||||
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 no issues found
|
||||
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 checkDomain(domain string, jsonOutput bool, outputFile string) {
|
||||
// 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)
|
||||
|
||||
var output string
|
||||
var fileOutput string
|
||||
|
||||
if jsonOutput {
|
||||
// Format as JSON
|
||||
jsonResult := issuestoJSON(domain, spfDmarcRecord, issues)
|
||||
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)
|
||||
|
||||
// For file output, wrap in square brackets for proper JSON array format
|
||||
if outputFile != "" {
|
||||
fileOutput = "[\n" + output + "\n]"
|
||||
} else {
|
||||
fileOutput = output
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
builder.WriteString("\n--- End of report ---\n")
|
||||
output = builder.String()
|
||||
fileOutput = output
|
||||
}
|
||||
|
||||
// Display the output
|
||||
fmt.Print(output)
|
||||
|
||||
// Save to file if specified
|
||||
if outputFile != "" {
|
||||
err := writeToFile(outputFile, fileOutput)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing to file %s: %v\n", outputFile, err)
|
||||
} else {
|
||||
fmt.Printf("Results saved to %s\n", outputFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write string data to a file
|
||||
func writeToFile(filename string, data string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.WriteString(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue