mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-26 11:44:37 +03:00
339 lines
12 KiB
Python
339 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DMARC Policy Enforcement Rollout Analyzer
|
|
|
|
Checks current DMARC, SPF, and DKIM status for a domain and provides
|
|
rollout recommendations. Parses DMARC aggregate reports to identify
|
|
sending sources and authentication failures.
|
|
|
|
Usage:
|
|
python process.py check --domain example.com
|
|
python process.py parse-report --report-file aggregate.xml
|
|
python process.py spf-audit --domain example.com
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field, asdict
|
|
from collections import defaultdict
|
|
|
|
try:
|
|
import dns.resolver
|
|
HAS_DNS = True
|
|
except ImportError:
|
|
HAS_DNS = False
|
|
|
|
|
|
@dataclass
|
|
class DMARCStatus:
|
|
"""Current DMARC deployment status."""
|
|
domain: str = ""
|
|
dmarc_record: str = ""
|
|
dmarc_policy: str = ""
|
|
dmarc_pct: int = 100
|
|
subdomain_policy: str = ""
|
|
rua_configured: bool = False
|
|
ruf_configured: bool = False
|
|
spf_record: str = ""
|
|
spf_valid: bool = False
|
|
spf_lookup_count: int = 0
|
|
dkim_found: bool = False
|
|
current_phase: str = ""
|
|
next_action: str = ""
|
|
issues: list = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class ReportSummary:
|
|
"""DMARC aggregate report summary."""
|
|
report_org: str = ""
|
|
report_domain: str = ""
|
|
date_range: str = ""
|
|
total_messages: int = 0
|
|
pass_count: int = 0
|
|
fail_count: int = 0
|
|
pass_rate: float = 0.0
|
|
sources: list = field(default_factory=list)
|
|
failing_sources: list = field(default_factory=list)
|
|
|
|
|
|
def count_spf_lookups(spf_record: str) -> int:
|
|
"""Count DNS lookups in SPF record (max 10 allowed)."""
|
|
lookup_count = 0
|
|
lookup_mechanisms = ['include:', 'a:', 'mx:', 'redirect=', 'exists:']
|
|
for mechanism in lookup_mechanisms:
|
|
lookup_count += spf_record.lower().count(mechanism)
|
|
# bare 'a' and 'mx' without ':' also count
|
|
parts = spf_record.split()
|
|
for part in parts:
|
|
p = part.lstrip('+').lstrip('-').lstrip('~').lstrip('?')
|
|
if p in ('a', 'mx'):
|
|
lookup_count += 1
|
|
return lookup_count
|
|
|
|
|
|
def check_dmarc_status(domain: str) -> DMARCStatus:
|
|
"""Check DMARC, SPF, and DKIM status for a domain."""
|
|
status = DMARCStatus(domain=domain)
|
|
|
|
if not HAS_DNS:
|
|
status.issues.append("dnspython not installed. Install with: pip install dnspython")
|
|
return status
|
|
|
|
# Check DMARC
|
|
try:
|
|
answers = dns.resolver.resolve(f"_dmarc.{domain}", 'TXT')
|
|
for rdata in answers:
|
|
txt = str(rdata).strip('"')
|
|
if txt.startswith('v=DMARC1'):
|
|
status.dmarc_record = txt
|
|
policy = re.search(r'p=(\w+)', txt)
|
|
if policy:
|
|
status.dmarc_policy = policy.group(1)
|
|
pct = re.search(r'pct=(\d+)', txt)
|
|
if pct:
|
|
status.dmarc_pct = int(pct.group(1))
|
|
sp = re.search(r'sp=(\w+)', txt)
|
|
if sp:
|
|
status.subdomain_policy = sp.group(1)
|
|
status.rua_configured = 'rua=' in txt
|
|
status.ruf_configured = 'ruf=' in txt
|
|
break
|
|
except Exception:
|
|
status.issues.append("No DMARC record found - start with p=none")
|
|
|
|
# Check SPF
|
|
try:
|
|
answers = dns.resolver.resolve(domain, 'TXT')
|
|
for rdata in answers:
|
|
txt = str(rdata).strip('"')
|
|
if txt.startswith('v=spf1'):
|
|
status.spf_record = txt
|
|
status.spf_lookup_count = count_spf_lookups(txt)
|
|
status.spf_valid = status.spf_lookup_count <= 10
|
|
if not status.spf_valid:
|
|
status.issues.append(
|
|
f"SPF record exceeds 10 lookup limit "
|
|
f"({status.spf_lookup_count} lookups). Use SPF flattening."
|
|
)
|
|
break
|
|
except Exception:
|
|
status.issues.append("No SPF record found")
|
|
|
|
# Check common DKIM selectors
|
|
selectors = ['default', 'google', 'selector1', 'selector2', 'proofpoint',
|
|
'mimecast', 's1', 's2', 'k1']
|
|
for selector in selectors:
|
|
try:
|
|
dns.resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
|
|
status.dkim_found = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not status.dkim_found:
|
|
status.issues.append("No DKIM record found for common selectors")
|
|
|
|
# Determine current phase and next action
|
|
if not status.dmarc_record:
|
|
status.current_phase = "Not started"
|
|
status.next_action = "Publish DMARC record with p=none and rua= for monitoring"
|
|
elif status.dmarc_policy == "none":
|
|
status.current_phase = "Monitoring (p=none)"
|
|
status.next_action = "Analyze reports, fix auth issues, then move to p=quarantine pct=10"
|
|
elif status.dmarc_policy == "quarantine":
|
|
if status.dmarc_pct < 100:
|
|
status.current_phase = f"Quarantine at {status.dmarc_pct}%"
|
|
next_pct = min(status.dmarc_pct * 2, 100)
|
|
status.next_action = f"Increase pct to {next_pct} after reviewing reports"
|
|
else:
|
|
status.current_phase = "Quarantine at 100%"
|
|
status.next_action = "Move to p=reject pct=10 after stable period"
|
|
elif status.dmarc_policy == "reject":
|
|
if status.dmarc_pct < 100:
|
|
status.current_phase = f"Reject at {status.dmarc_pct}%"
|
|
next_pct = min(status.dmarc_pct * 2, 100)
|
|
status.next_action = f"Increase pct to {next_pct} after reviewing reports"
|
|
else:
|
|
status.current_phase = "FULL ENFORCEMENT (p=reject 100%)"
|
|
status.next_action = "Maintain: monitor reports, update auth for new sources"
|
|
|
|
if not status.rua_configured and status.dmarc_record:
|
|
status.issues.append("No rua= tag - add aggregate report destination")
|
|
|
|
if status.dmarc_policy == "reject" and not status.subdomain_policy:
|
|
status.issues.append("No subdomain policy (sp=) - subdomains can still be spoofed")
|
|
|
|
return status
|
|
|
|
|
|
def parse_dmarc_report(xml_file: str) -> ReportSummary:
|
|
"""Parse DMARC aggregate report XML."""
|
|
summary = ReportSummary()
|
|
|
|
tree = ET.parse(xml_file)
|
|
root = tree.getroot()
|
|
|
|
# Report metadata
|
|
metadata = root.find('.//report_metadata')
|
|
if metadata is not None:
|
|
org = metadata.find('org_name')
|
|
if org is not None:
|
|
summary.report_org = org.text or ""
|
|
|
|
policy = root.find('.//policy_published')
|
|
if policy is not None:
|
|
domain = policy.find('domain')
|
|
if domain is not None:
|
|
summary.report_domain = domain.text or ""
|
|
|
|
date_range = root.find('.//date_range')
|
|
if date_range is not None:
|
|
begin = date_range.find('begin')
|
|
end = date_range.find('end')
|
|
if begin is not None and end is not None:
|
|
summary.date_range = f"{begin.text} - {end.text}"
|
|
|
|
# Process records
|
|
for record in root.findall('.//record'):
|
|
row = record.find('row')
|
|
if row is None:
|
|
continue
|
|
|
|
source_ip = ""
|
|
count = 0
|
|
spf_result = ""
|
|
dkim_result = ""
|
|
disposition = ""
|
|
|
|
ip_elem = row.find('source_ip')
|
|
if ip_elem is not None:
|
|
source_ip = ip_elem.text or ""
|
|
|
|
count_elem = row.find('count')
|
|
if count_elem is not None:
|
|
count = int(count_elem.text or 0)
|
|
|
|
policy_eval = row.find('policy_evaluated')
|
|
if policy_eval is not None:
|
|
dkim_elem = policy_eval.find('dkim')
|
|
spf_elem = policy_eval.find('spf')
|
|
disp_elem = policy_eval.find('disposition')
|
|
if dkim_elem is not None:
|
|
dkim_result = dkim_elem.text or ""
|
|
if spf_elem is not None:
|
|
spf_result = spf_elem.text or ""
|
|
if disp_elem is not None:
|
|
disposition = disp_elem.text or ""
|
|
|
|
summary.total_messages += count
|
|
passed = (spf_result == "pass" or dkim_result == "pass")
|
|
|
|
source_info = {
|
|
"source_ip": source_ip,
|
|
"count": count,
|
|
"spf": spf_result,
|
|
"dkim": dkim_result,
|
|
"disposition": disposition,
|
|
"passed": passed
|
|
}
|
|
summary.sources.append(source_info)
|
|
|
|
if passed:
|
|
summary.pass_count += count
|
|
else:
|
|
summary.fail_count += count
|
|
summary.failing_sources.append(source_info)
|
|
|
|
if summary.total_messages > 0:
|
|
summary.pass_rate = (summary.pass_count / summary.total_messages) * 100
|
|
|
|
return summary
|
|
|
|
|
|
def format_status_report(status: DMARCStatus) -> str:
|
|
"""Format DMARC status as readable report."""
|
|
lines = []
|
|
lines.append("=" * 60)
|
|
lines.append(" DMARC ENFORCEMENT ROLLOUT STATUS")
|
|
lines.append("=" * 60)
|
|
lines.append(f" Domain: {status.domain}")
|
|
lines.append(f" Current Phase: {status.current_phase}")
|
|
lines.append(f" Next Action: {status.next_action}")
|
|
lines.append("")
|
|
lines.append(f" DMARC Record: {status.dmarc_record or 'NOT FOUND'}")
|
|
lines.append(f" DMARC Policy: {status.dmarc_policy or 'N/A'}")
|
|
lines.append(f" DMARC pct: {status.dmarc_pct}%")
|
|
lines.append(f" Subdomain Policy: {status.subdomain_policy or 'Not set'}")
|
|
lines.append(f" RUA Reports: {'Yes' if status.rua_configured else 'No'}")
|
|
lines.append(f" RUF Reports: {'Yes' if status.ruf_configured else 'No'}")
|
|
lines.append("")
|
|
lines.append(f" SPF Record: {status.spf_record or 'NOT FOUND'}")
|
|
lines.append(f" SPF Lookups: {status.spf_lookup_count}/10")
|
|
lines.append(f" SPF Valid: {'Yes' if status.spf_valid else 'No'}")
|
|
lines.append(f" DKIM Found: {'Yes' if status.dkim_found else 'No'}")
|
|
|
|
if status.issues:
|
|
lines.append(f"\n [ISSUES] ({len(status.issues)})")
|
|
for i, issue in enumerate(status.issues, 1):
|
|
lines.append(f" {i}. {issue}")
|
|
|
|
lines.append("=" * 60)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="DMARC Policy Enforcement Rollout Analyzer")
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
check_parser = subparsers.add_parser("check", help="Check DMARC deployment status")
|
|
check_parser.add_argument("--domain", required=True)
|
|
|
|
report_parser = subparsers.add_parser("parse-report", help="Parse DMARC aggregate report")
|
|
report_parser.add_argument("--report-file", required=True)
|
|
|
|
spf_parser = subparsers.add_parser("spf-audit", help="Audit SPF record")
|
|
spf_parser.add_argument("--domain", required=True)
|
|
|
|
parser.add_argument("--json", action="store_true")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "check":
|
|
result = check_dmarc_status(args.domain)
|
|
if args.json:
|
|
print(json.dumps(asdict(result), indent=2))
|
|
else:
|
|
print(format_status_report(result))
|
|
|
|
elif args.command == "parse-report":
|
|
result = parse_dmarc_report(args.report_file)
|
|
if args.json:
|
|
print(json.dumps(asdict(result), indent=2))
|
|
else:
|
|
print(f"Report from: {result.report_org}")
|
|
print(f"Domain: {result.report_domain}")
|
|
print(f"Total messages: {result.total_messages}")
|
|
print(f"Pass rate: {result.pass_rate:.1f}%")
|
|
if result.failing_sources:
|
|
print(f"\nFailing sources ({len(result.failing_sources)}):")
|
|
for src in result.failing_sources:
|
|
print(f" IP: {src['source_ip']} | Count: {src['count']} | "
|
|
f"SPF: {src['spf']} | DKIM: {src['dkim']}")
|
|
|
|
elif args.command == "spf-audit":
|
|
status = check_dmarc_status(args.domain)
|
|
print(f"SPF Record: {status.spf_record}")
|
|
print(f"DNS Lookups: {status.spf_lookup_count}/10")
|
|
print(f"Valid: {'Yes' if status.spf_valid else 'OVER LIMIT'}")
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|