Security · python · 22 copies
Phishing Email Analyzer
Forensic analysis of suspicious emails (.eml / .msg files). Parses email headers to trace the true origin (Received headers chain), validates SPF, DKIM, and DMARC authentication records, extracts and checks URLs against threat intelligence (VirusTotal, URLhaus), analyzes attachments (SHA256 hash, file type detection, macro/OLE detection in Office files), detects display name spoofing and lookalike domains, and generates a full threat report. Essential for SOC tier-1 triage and incident response.
- Linux
- Windows
- macOS
pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4" "python-magic>=0.4"#!/usr/bin/env python3
"""
Phishing Email Analyzer
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/phishing-email-analyzer
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4" "python-magic>=0.4"
"""
import argparse
import email
import email.policy
import hashlib
import json
import os
import re
import sys
from email import message_from_bytes
from pathlib import Path
from typing import Optional
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
import dns.resolver
import dns.exception
except ImportError:
print("[!] Missing dependency: pip install dnspython")
sys.exit(1)
try:
import requests
except ImportError:
print("[!] Missing dependency: pip install requests")
sys.exit(1)
try:
from colorama import init, Fore, Style
init(autoreset=True)
except ImportError:
class Fore:
RED = YELLOW = GREEN = CYAN = WHITE = RESET = ""
class Style:
BRIGHT = RESET_ALL = ""
# Homoglyph map for lookalike domain detection
HOMOGLYPHS = {
"a": ["@", "а", "ā", "ă"],
"e": ["е", "ē", "ĕ"],
"i": ["і", "ī", "ĭ"],
"o": ["о", "ō", "ŏ"],
"l": ["1", "I", "ĺ", "ļ"],
"p": ["р"],
}
KNOWN_BRANDS = [
"paypal", "apple", "google", "microsoft", "amazon", "facebook",
"instagram", "twitter", "linkedin", "netflix", "dropbox", "docusign",
"stripe", "chase", "wellsfargo", "bankofamerica", "irs", "dhl",
"fedex", "ups",
]
SUSPICIOUS_TLDS = {
".xyz", ".top", ".club", ".online", ".site",
".tk", ".ml", ".ga", ".cf", ".click",
}
OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
OOXML_MAGIC = b"PK\x03\x04"
def parse_eml(filepath: str) -> dict:
"""Parse RFC 2822 .eml file; extract headers, body, attachments."""
raw = Path(filepath).read_bytes()
msg = message_from_bytes(raw, policy=email.policy.compat32)
headers = dict(msg.items())
text_body, html_body = "", ""
attachments = []
def walk(part):
nonlocal text_body, html_body
ct = part.get_content_type()
cd = part.get_content_disposition() or ""
if ct == "text/plain" and "attachment" not in cd:
text_body += part.get_payload(decode=True).decode("utf-8", errors="replace")
elif ct == "text/html" and "attachment" not in cd:
html_body += part.get_payload(decode=True).decode("utf-8", errors="replace")
elif "attachment" in cd or part.get_filename():
payload = part.get_payload(decode=True) or b""
attachments.append({
"filename": part.get_filename() or "unnamed",
"content_type": ct,
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"data": payload,
})
if msg.is_multipart():
for part in msg.walk():
walk(part)
else:
walk(msg)
return {
"headers": headers,
"text_body": text_body,
"html_body": html_body,
"attachments": attachments,
"raw_size": len(raw),
}
def trace_received_headers(headers: dict) -> list:
"""Parse Received header chain to find the true originating IP."""
hops = []
received_list = [v for k, v in headers.items() if k.lower() == "received"]
received_list.reverse() # oldest hop first
from_re = re.compile(
r"from\s+(\S+)\s+\((\S+)\s+\[(\d{1,3}(?:\.\d{1,3}){3})\]", re.I
)
ip_re = re.compile(r"\[(\d{1,3}(?:\.\d{1,3}){3})\]")
for i, rcv in enumerate(received_list, 1):
m = from_re.search(rcv)
if m:
hops.append({"hop": i, "from_host": m.group(1), "helo": m.group(2), "ip": m.group(3)})
else:
ips = ip_re.findall(rcv)
if ips:
hops.append({"hop": i, "ip": ips[0]})
return hops
def parse_auth_results(headers: dict) -> dict:
"""
Read the Authentication-Results header the receiving server wrote.
This is the authoritative record. The receiving MTA evaluated SPF, DKIM and
DMARC at delivery time, with live DNS, against the real connecting IP, and
following include: chains properly - none of which can be reproduced from a
saved .eml days later. When this header is present, trust it over a fresh
DNS lookup.
Format (RFC 8601), one or more of:
Authentication-Results: mx.example.com; spf=pass smtp.mailfrom=a.com;
dkim=pass header.d=a.com; dmarc=pass header.from=a.com
Returns {"spf": "pass", "dkim": "fail", "dmarc": "none", ...} with only the
methods that were actually reported.
"""
raw = []
for key in ("Authentication-Results", "authentication-results",
"ARC-Authentication-Results"):
v = headers.get(key)
if v:
raw.append(v)
if not raw:
return {}
blob = " ".join(raw).lower()
out = {}
# Each method appears as `method=result`, with optional properties after.
for method in ("spf", "dkim", "dmarc", "compauth"):
m = re.search(rf"\b{method}\s*=\s*([a-z]+)", blob)
if m:
out[method] = m.group(1)
if out:
out["_source"] = "Authentication-Results header"
return out
# How a reported result maps to risk. "temperror" and "permerror" mean the
# receiving server could not complete the check - that is not a failure.
_SPF_RISK = {
"pass": ("PASS", "OK"),
"fail": ("FAIL", "HIGH"),
"softfail": ("SOFTFAIL", "MEDIUM"),
"neutral": ("NEUTRAL", "LOW"),
"none": ("NONE", "LOW"),
"temperror": ("UNKNOWN", "UNKNOWN"),
"permerror": ("UNKNOWN", "UNKNOWN"),
}
_DKIM_RISK = {
"pass": ("PASS", "OK"),
"fail": ("FAIL", "HIGH"),
"none": ("NONE", "LOW"),
"neutral": ("NEUTRAL", "LOW"),
"policy": ("NEUTRAL", "LOW"),
"temperror": ("UNKNOWN", "UNKNOWN"),
"permerror": ("UNKNOWN", "UNKNOWN"),
}
_DMARC_RISK = {
"pass": ("PASS", "OK"),
"fail": ("FAIL", "HIGH"),
"none": ("NONE", "LOW"),
"bestguesspass": ("NEUTRAL", "LOW"),
"temperror": ("UNKNOWN", "UNKNOWN"),
"permerror": ("UNKNOWN", "UNKNOWN"),
}
def check_spf(from_domain: str, sender_ip: str, auth: dict = None) -> dict:
"""
SPF result for the message.
Prefers the receiving server's own verdict. Falls back to a DNS lookup that
reports what the record SAYS but refuses to claim pass or fail, because
evaluating SPF properly means following include: chains, expanding macros
and matching CIDR ranges - and a message that is legitimately sent through
Google Workspace or Microsoft 365 has no literal ip4:<ip> in its record.
Guessing here is how a tool ends up calling real mail a forgery.
"""
auth = auth or {}
if "spf" in auth:
result, risk = _SPF_RISK.get(auth["spf"], ("UNKNOWN", "UNKNOWN"))
return {"result": result, "risk": risk, "source": auth["_source"],
"explanation": f"Receiving server reported spf={auth['spf']}."}
if not from_domain:
return {"result": "UNKNOWN", "risk": "UNKNOWN",
"explanation": "No From domain to check."}
try:
answers = dns.resolver.resolve(from_domain, "TXT", lifetime=5)
except Exception as exc:
return {"result": "UNKNOWN", "risk": "UNKNOWN",
"explanation": f"SPF could not be checked - DNS lookup failed "
f"({type(exc).__name__}). This is not an SPF failure."}
for r in answers:
txt = r.to_text().strip('"')
if not txt.startswith("v=spf1"):
continue
# A literal match is the only case we can assert from the record alone.
if sender_ip and f"ip4:{sender_ip}" in txt:
return {"result": "PASS", "record": txt, "risk": "OK",
"explanation": f"{sender_ip} is listed literally in the SPF record."}
if re.search(r"\b(include:|redirect=|\ba\b|\bmx\b|exists:)", txt):
return {"result": "UNKNOWN", "risk": "UNKNOWN", "record": txt,
"explanation": "SPF record delegates with include:/a/mx, which cannot be "
"evaluated from the record alone. No Authentication-Results "
"header present, so SPF is undetermined - not a failure."}
if "-all" in txt:
return {"result": "FAIL", "record": txt, "risk": "HIGH",
"explanation": f"SPF '-all' policy with no delegation, and {sender_ip or 'the sender'} "
f"is not listed - not an authorized sender."}
if "~all" in txt:
return {"result": "SOFTFAIL", "record": txt, "risk": "MEDIUM",
"explanation": "SPF softfail - sender not authorized, policy is advisory only."}
return {"result": "NEUTRAL", "record": txt, "risk": "LOW"}
return {"result": "NONE", "risk": "LOW",
"explanation": "No SPF record published for this domain. Weak, but on its own "
"it is common for small domains and not evidence of phishing."}
def check_dkim(headers: dict, auth: dict = None) -> dict:
"""
DKIM result for the message.
Note what a DNS lookup can and cannot prove here: finding the selector in
DNS shows a key is published, NOT that this message's signature validates
against it. Verifying the signature needs the canonicalised body and the
public key, so the fallback reports the weaker fact honestly rather than
labelling it PASS.
"""
auth = auth or {}
if "dkim" in auth:
result, risk = _DKIM_RISK.get(auth["dkim"], ("UNKNOWN", "UNKNOWN"))
return {"result": result, "risk": risk, "source": auth["_source"],
"explanation": f"Receiving server reported dkim={auth['dkim']}."}
sig = headers.get("DKIM-Signature") or headers.get("dkim-signature")
if not sig:
return {"result": "NONE", "risk": "LOW",
"explanation": "No DKIM-Signature header. Plenty of legitimate internal mail "
"is unsigned, so on its own this is weak evidence."}
domain_m = re.search(r"d=([^;]+)", sig)
domain = domain_m.group(1).strip() if domain_m else "unknown"
sel_m = re.search(r"s=([^;]+)", sig)
selector = sel_m.group(1).strip() if sel_m else "default"
try:
dns.resolver.resolve(f"{selector}._domainkey.{domain}", "TXT", lifetime=5)
return {"result": "KEY_PUBLISHED", "domain": domain, "selector": selector,
"risk": "LOW",
"explanation": f"Signing key {selector}._domainkey.{domain} exists in DNS. "
f"The signature itself was not verified - that needs the "
f"canonicalised message body."}
except Exception as exc:
return {"result": "UNKNOWN", "domain": domain, "selector": selector,
"risk": "UNKNOWN",
"explanation": f"DKIM selector {selector}._domainkey.{domain} could not be "
f"looked up ({type(exc).__name__}). Note the key may also have "
f"been rotated out since delivery."}
def check_dmarc(from_domain: str, auth: dict = None) -> dict:
"""
DMARC result for the message, and the domain's published policy.
These are two different things and both matter: `dmarc=fail` means THIS
message failed alignment, while `p=none` means the domain publishes no
enforcement. A failure is evidence about the message; a weak policy is
context about the domain.
"""
auth = auth or {}
reported = None
if "dmarc" in auth:
result, risk = _DMARC_RISK.get(auth["dmarc"], ("UNKNOWN", "UNKNOWN"))
reported = {"result": result, "risk": risk, "source": auth["_source"],
"explanation": f"Receiving server reported dmarc={auth['dmarc']}."}
if result == "FAIL":
return reported
if not from_domain:
return reported or {"result": "UNKNOWN", "risk": "UNKNOWN",
"explanation": "No From domain to check."}
try:
answers = dns.resolver.resolve(f"_dmarc.{from_domain}", "TXT", lifetime=5)
except Exception as exc:
return reported or {
"result": "UNKNOWN", "risk": "UNKNOWN",
"explanation": f"DMARC could not be checked - DNS lookup failed "
f"({type(exc).__name__}). This is not a DMARC failure."}
for r in answers:
txt = r.to_text().strip('"')
if "v=DMARC1" not in txt:
continue
p_m = re.search(r"p=(\w+)", txt)
policy = p_m.group(1).lower() if p_m else "none"
# The policy describes the domain's posture, not this message's verdict.
policy_risk = {"reject": "OK", "quarantine": "LOW", "none": "MEDIUM"}.get(policy, "MEDIUM")
out = {"result": reported["result"] if reported else "FOUND",
"policy": policy, "record": txt,
"risk": reported["risk"] if reported else policy_risk,
"explanation": (f"DMARC p={policy} - "
f"{'enforced' if policy != 'none' else 'monitoring only, not enforced'}")}
if reported:
out["explanation"] = reported["explanation"] + f" Domain publishes p={policy}."
out["source"] = reported["source"]
return out
return reported or {
"result": "NONE", "risk": "MEDIUM",
"explanation": "No DMARC record published - the domain has no enforcement policy, "
"so anyone can spoof it without the receiver rejecting the mail."}
def detect_display_spoofing(from_header: str) -> Optional[dict]:
"""Check if display name claims a known brand but the sending domain doesn't match."""
m = re.match(r'"?([^<"]+?)"?\s*<([^>]+)>', from_header)
if not m:
return None
display_name = m.group(1).lower()
address = m.group(2).lower()
domain = address.split("@")[-1] if "@" in address else address
for brand in KNOWN_BRANDS:
if brand in display_name and brand not in domain:
return {
"spoofed": True,
"claimed_brand": brand,
"actual_domain": domain,
"risk": "CRITICAL",
"explanation": f'Display name claims to be "{brand}" but email comes from {domain}',
}
return None
# The domains each brand actually sends mail from. A message whose domain
# contains the brand name but is not one of these is impersonating it - which
# is the whole point of a lookalike domain.
BRAND_DOMAINS = {
"paypal": {"paypal.com", "paypal.co.uk", "paypal-communication.com"},
"apple": {"apple.com", "icloud.com", "me.com"},
"google": {"google.com", "gmail.com", "googlemail.com", "youtube.com"},
"microsoft": {"microsoft.com", "office.com", "office365.com",
"outlook.com", "hotmail.com", "live.com", "azure.com",
"microsoftonline.com", "sharepointonline.com"},
"amazon": {"amazon.com", "amazon.co.uk", "amazonses.com", "aws.amazon.com"},
"facebook": {"facebook.com", "facebookmail.com", "fb.com", "meta.com"},
"instagram": {"instagram.com", "mail.instagram.com"},
"twitter": {"twitter.com", "x.com"},
"linkedin": {"linkedin.com"},
"netflix": {"netflix.com"},
"dropbox": {"dropbox.com", "dropboxmail.com"},
"docusign": {"docusign.com", "docusign.net"},
"stripe": {"stripe.com"},
"chase": {"chase.com", "jpmorgan.com"},
"wellsfargo": {"wellsfargo.com"},
"bankofamerica": {"bankofamerica.com", "bofa.com"},
"irs": {"irs.gov"},
"dhl": {"dhl.com", "dhl.de"},
"fedex": {"fedex.com"},
"ups": {"ups.com"},
}
def registrable_domain(domain: str) -> str:
"""
The part a phisher had to register, e.g. mail.paypa1-verify.co.uk ->
paypa1-verify.co.uk. Subdomains are free to the attacker, so comparing
them would be meaningless: anyone can create paypal.com.evil.tk.
"""
parts = domain.lower().strip(".").split(".")
if len(parts) <= 2:
return ".".join(parts)
# Handle the common two-part public suffixes without pulling in a PSL dep.
two_part = {"co.uk", "com.au", "co.jp", "com.br", "co.nz", "co.za",
"com.mx", "org.uk", "ac.uk", "gov.uk", "com.cn"}
if ".".join(parts[-2:]) in two_part:
return ".".join(parts[-3:])
return ".".join(parts[-2:])
def check_lookalike_domain(domain: str) -> dict:
"""
Detect domains impersonating a known brand.
Checks the registrable domain, not just the first label, and catches the
brand being EMBEDDED among other words - paypal-verify, secure-apple-login,
microsoft-365-auth - which is how most phishing domains are actually built.
"""
domain = (domain or "").lower().strip(".")
if not domain:
return {"lookalike": False}
reg = registrable_domain(domain)
label = reg.rsplit(".", 1)[0] if "." in reg else reg # drop the public suffix
tld = "." + reg.split(".")[-1] if "." in reg else ""
suspicious_tld = tld in SUSPICIOUS_TLDS
# Normalize homoglyph and digit substitutions: paypa1 -> paypal.
normalized = label
for char, glyphs in HOMOGLYPHS.items():
for g in glyphs:
normalized = normalized.replace(g, char)
# Strip separators so "pay-pal" and "pay.pal" collapse to "paypal".
squashed = normalized.replace("-", "").replace("_", "").replace(".", "")
for brand in KNOWN_BRANDS:
legit = BRAND_DOMAINS.get(brand, set())
if reg in legit:
return {"lookalike": False, "brand": brand,
"explanation": f"{reg} is a legitimate {brand} domain."}
# Does the brand appear at all, after undoing substitutions?
brand_present = brand in squashed
homoglyph_used = brand_present and brand not in label.replace("-", "").replace("_", "")
if brand_present:
# The brand name is here but the domain is not the brand's. That is
# impersonation regardless of how many extra words surround it.
if homoglyph_used:
return {"lookalike": True, "brand": brand, "type": "homoglyph",
"risk": "CRITICAL", "registrable_domain": reg,
"suspicious_tld": suspicious_tld, "tld": tld,
"explanation": (f"{reg} impersonates {brand} using lookalike character "
f"substitutions ({label} normalizes to {normalized})"
+ (f", on abused TLD {tld}" if suspicious_tld else ""))}
if squashed == brand:
# e.g. paypal.tk - the bare brand on a domain the brand does not own.
return {"lookalike": True, "brand": brand, "type": "brand_squat",
"risk": "CRITICAL", "registrable_domain": reg,
"suspicious_tld": suspicious_tld, "tld": tld,
"explanation": (f"{reg} uses the {brand} name but is not a {brand} "
f"domain (real: {', '.join(sorted(legit)) or 'n/a'})")}
return {"lookalike": True, "brand": brand, "type": "brand_embedded",
"risk": "CRITICAL" if suspicious_tld else "HIGH",
"registrable_domain": reg,
"suspicious_tld": suspicious_tld, "tld": tld,
"explanation": (f"{reg} embeds the {brand} name among other words - the "
f"standard phishing pattern. Real {brand} mail comes from "
f"{', '.join(sorted(legit)) or 'n/a'}"
+ (f". Also on abused TLD {tld}" if suspicious_tld else ""))}
# Typosquat: same length as the brand, one character different.
if len(squashed) == len(brand) and sum(a != b for a, b in zip(squashed, brand)) == 1:
return {"lookalike": True, "brand": brand, "type": "typosquat", "distance": 1,
"risk": "HIGH", "registrable_domain": reg,
"suspicious_tld": suspicious_tld, "tld": tld,
"explanation": f"{reg} is one character away from {brand} (typosquat)"}
# The brand may instead sit in a SUBDOMAIN, which costs the attacker nothing:
# paypal.com.evil.tk, apple.com.verify.xyz, login.microsoft.com.secure.cf.
# The registrable domain is evil.tk, so the checks above cannot see it - but
# there is no legitimate reason for another company's brand to appear there.
sub = domain[:-len(reg)].rstrip(".") if domain.endswith(reg) else ""
if sub:
sub_norm = sub
for char, glyphs in HOMOGLYPHS.items():
for g in glyphs:
sub_norm = sub_norm.replace(g, char)
sub_squashed = sub_norm.replace("-", "").replace("_", "").replace(".", "")
for brand in KNOWN_BRANDS:
if brand in sub_squashed:
return {"lookalike": True, "brand": brand, "type": "subdomain_spoof",
"risk": "CRITICAL", "registrable_domain": reg,
"subdomain": sub, "suspicious_tld": suspicious_tld, "tld": tld,
"explanation": (f"'{brand}' appears in the subdomain '{sub}' but the "
f"domain actually registered is {reg}. Subdomains are "
f"free to the attacker - only {reg} identifies the "
f"sender, and it is not "
f"{', '.join(sorted(BRAND_DOMAINS.get(brand, {'n/a'})))}")}
if suspicious_tld:
return {"lookalike": False, "suspicious_tld": True, "tld": tld,
"registrable_domain": reg, "risk": "MEDIUM",
"explanation": f"{tld} is a commonly abused TLD used in phishing campaigns"}
return {"lookalike": False, "registrable_domain": reg}
def extract_urls(body: str) -> list:
"""Extract all unique URLs from HTML/text body."""
url_re = re.compile(r"https?://[^\s\"'<>)\]]+", re.I)
seen, urls = set(), []
for m in url_re.finditer(body):
u = m.group(0).rstrip(".,;)")
if u not in seen:
seen.add(u)
urls.append(u)
return urls
def check_urlhaus(url: str) -> dict:
"""URLhaus free API — check if URL is a known malware/phishing host (no API key needed)."""
try:
resp = requests.post(
"https://urlhaus-api.abuse.ch/v1/url/",
data={"url": url},
timeout=8,
)
data = resp.json()
if data.get("query_status") == "is_listed":
return {"listed": True, "threat": data.get("threat", "unknown"),
"date_added": data.get("date_added", ""), "risk": "CRITICAL"}
return {"listed": False, "risk": "OK"}
except Exception:
return {"listed": False, "risk": "UNKNOWN"}
def check_url_threat_intel(url: str, vt_key: Optional[str]) -> dict:
"""VirusTotal URL check + URLhaus lookup."""
result = {"url": url, "urlhaus": check_urlhaus(url)}
if vt_key:
try:
import base64
url_id = base64.urlsafe_b64encode(url.encode()).decode().rstrip("=")
resp = requests.get(
f"https://www.virustotal.com/api/v3/urls/{url_id}",
headers={"x-apikey": vt_key},
timeout=10,
)
if resp.status_code == 200:
stats = resp.json()["data"]["attributes"]["last_analysis_stats"]
malicious = stats.get("malicious", 0)
total = sum(stats.values())
result["virustotal"] = {
"malicious": malicious, "total": total,
"risk": "CRITICAL" if malicious >= 5 else "HIGH" if malicious >= 2 else "LOW",
}
except Exception as exc:
result["virustotal"] = {"error": str(exc)}
return result
def analyze_attachment(att: dict) -> dict:
"""MIME type detection, SHA256, OLE/OOXML macro detection, MalwareBazaar lookup."""
data = att.get("data", b"")
findings = {
"filename": att["filename"],
"size": att["size"],
"sha256": att["sha256"],
"content_type": att["content_type"],
"has_macros": False,
"double_extension": False,
"risk": "LOW",
}
# A filename like invoice.pdf.exe shows as "invoice.pdf" wherever the mail
# client hides known extensions, which is the Windows default. The user
# believes they are opening a PDF.
name = att["filename"]
parts = name.lower().split(".")
if len(parts) >= 3:
decoy, real = parts[-2], parts[-1]
DOC_LIKE = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
"jpg", "jpeg", "png", "gif", "csv", "rtf", "htm", "html"}
EXECUTABLE = {"exe", "scr", "com", "pif", "bat", "cmd", "vbs", "vbe",
"js", "jse", "wsf", "wsh", "hta", "msi", "ps1", "jar",
"lnk", "cpl", "reg", "iso", "img"}
if decoy in DOC_LIKE and real in EXECUTABLE:
findings["double_extension"] = True
findings["risk"] = "CRITICAL"
findings["double_extension_warning"] = (
f"Filename '{name}' looks like a .{decoy} but is actually a .{real} "
f"executable. Windows hides known extensions by default, so the "
f"recipient sees '{'.'.join(parts[:-1])}'. DO NOT OPEN.")
# A right-to-left override character reverses how the rest of the filename
# is DISPLAYED: "invoicefdp.exe" renders as "invoiceexe.pdf".
if "" in name or "" in name or "" in name:
findings["double_extension"] = True
findings["risk"] = "CRITICAL"
findings["rtl_override_warning"] = (
"Filename contains a right-to-left override character, which reverses "
"how the extension is displayed. The real extension is not what the "
"recipient sees. DO NOT OPEN.")
if data[:8] == OLE_MAGIC:
findings["file_format"] = "OLE Compound Document (older Office format)"
macro_streams = [b"VBA", b"Macros", b"_VBA_PROJECT", b"vbaProject.bin"]
for stream in macro_streams:
if stream in data:
findings["has_macros"] = True
findings["risk"] = "CRITICAL"
findings["macro_warning"] = (
"VBA macros detected. DO NOT OPEN — quarantine and submit to sandbox."
)
break
elif data[:4] == OOXML_MAGIC:
findings["file_format"] = "OOXML (modern Office format)"
if b"vbaProject.bin" in data or b"xl/vbaProject.bin" in data:
findings["has_macros"] = True
findings["risk"] = "CRITICAL"
findings["macro_warning"] = "Embedded VBA macro project detected in OOXML archive."
elif data[:4] == b"%PDF":
findings["file_format"] = "PDF"
if b"/JS " in data or b"/JavaScript" in data or b"/Launch" in data:
findings["has_embedded_js"] = True
findings["risk"] = "HIGH"
findings["macro_warning"] = (
"PDF contains embedded JavaScript or /Launch action — possible exploit."
)
try:
resp = requests.post(
"https://mb-api.abuse.ch/api/v1/",
data={"query": "get_info", "hash": findings["sha256"]},
timeout=8,
)
mb = resp.json()
if mb.get("query_status") == "ok":
info = mb["data"][0]
findings["malwarebazaar"] = {
"listed": True,
"malware_family": info.get("signature", "unknown"),
"tags": info.get("tags", []),
"risk": "CRITICAL",
}
except Exception:
pass
return findings
def score_phishing(findings: dict) -> dict:
"""
Produce an overall verdict: MALICIOUS / SUSPICIOUS / LIKELY_SAFE.
Weighting reflects how much each signal actually proves. An explicit
spf=fail or dmarc=fail from the receiving server is strong evidence of
spoofing. A missing DKIM signature, or a domain with no DMARC policy, is
weak on its own - lots of legitimate small-business mail looks like that,
and scoring it heavily is what buries real detections in false positives.
An UNKNOWN result - the check could not be completed - contributes nothing.
A tool that cannot reach DNS should say so, not call every message
suspicious.
"""
score = 0
reasons = []
def add(points: int, why: str) -> None:
nonlocal score
score += points
reasons.append(f"+{points} {why}")
if findings.get("display_spoofing", {}).get("spoofed"):
add(40, "display name impersonates a brand that does not match the sending domain")
# Authentication. FAIL is strong, absence is weak, UNKNOWN is nothing.
auth_failed = []
for key, label in (("spf", "SPF"), ("dkim", "DKIM"), ("dmarc", "DMARC")):
res = findings.get(key, {})
result, risk = res.get("result"), res.get("risk")
if risk == "UNKNOWN" or result in ("UNKNOWN", "SKIP"):
continue
if result == "FAIL":
auth_failed.append(label)
elif result in ("SOFTFAIL",):
add(8, f"{label} softfail")
elif result == "NONE" and key == "dmarc":
add(5, "domain publishes no DMARC policy (spoofable, but common)")
if auth_failed:
# Two or more hard failures together is the classic spoofing signature.
pts = 45 if len(auth_failed) >= 2 else 25
add(pts, f"{' + '.join(auth_failed)} hard failure"
f"{' (combined - strong spoofing indicator)' if len(auth_failed) >= 2 else ''}")
la = findings.get("lookalike", {})
if la.get("lookalike"):
brand = la.get("brand", "a known brand")
kind = {"homoglyph": "using lookalike characters",
"brand_squat": "squatting the brand name",
"brand_embedded": "embedding the brand name among other words",
"typosquat": "one character off"}.get(la.get("type"), "")
add(35 if la.get("risk") == "CRITICAL" else 25,
f"sending domain impersonates {brand} ({kind})")
elif la.get("suspicious_tld"):
add(10, f"sending domain uses {la.get('tld')}, a TLD commonly abused in phishing")
for url_r in findings.get("url_results", []):
if url_r.get("urlhaus", {}).get("listed"):
add(40, f"URL listed in URLhaus: {url_r.get('url', '')[:60]}")
vt = url_r.get("virustotal", {}).get("malicious", 0)
if vt >= 5:
add(30, f"URL flagged malicious by {vt} VirusTotal engines")
elif vt >= 1:
add(10, f"URL flagged malicious by {vt} VirusTotal engine(s)")
for att in findings.get("attachment_results", []):
if att.get("has_macros"):
add(40, f"attachment contains macros: {att.get('filename', '')}")
if att.get("malwarebazaar", {}).get("listed"):
add(50, f"attachment hash known to MalwareBazaar: {att.get('filename', '')}")
if att.get("double_extension"):
add(30, f"attachment uses a double extension: {att.get('filename', '')}")
verdict = ("MALICIOUS" if score >= 60 else
"SUSPICIOUS" if score >= 25 else "LIKELY_SAFE")
# Say so explicitly when the verdict rests on incomplete data.
unknowns = [k.upper() for k in ("spf", "dkim", "dmarc")
if findings.get(k, {}).get("risk") == "UNKNOWN"]
return {"verdict": verdict, "confidence_pct": min(99, score), "score": score,
"reasons": reasons or ["no phishing indicators found"],
"undetermined_checks": unknowns}
def main():
ap = argparse.ArgumentParser(description="Phishing Email Analyzer")
ap.add_argument("--file", required=True)
ap.add_argument("--output", default="phishing_report.json")
ap.add_argument("--extract-attachments", action="store_true",
help="Save attachments to ./attachments/")
args = ap.parse_args()
if not os.path.isfile(args.file):
print(f"{Fore.RED}[-] File not found: {args.file}")
sys.exit(1)
size_kb = os.path.getsize(args.file)
print(f"{Fore.CYAN}[*] Phishing Email Analyzer | File: {args.file} ({size_kb:,} bytes)")
parsed = parse_eml(args.file)
headers = parsed["headers"]
findings = {}
# ── Header summary ────────────────────────────────────────────────────────
from_hdr = headers.get("From", "unknown")
to_hdr = headers.get("To", "unknown")
subject_hdr = headers.get("Subject", "")
date_hdr = headers.get("Date", "")
print(f"\n{Fore.CYAN}[*] Email Headers")
print(f" {Fore.WHITE}From: {from_hdr}")
print(f" {Fore.WHITE}To: {to_hdr}")
print(f" {Fore.WHITE}Subject: {subject_hdr}")
print(f" {Fore.WHITE}Date: {date_hdr}")
# ── Display name spoofing ─────────────────────────────────────────────────
spoof = detect_display_spoofing(from_hdr)
findings["display_spoofing"] = spoof or {}
if spoof and spoof.get("spoofed"):
print(f"\n {Fore.RED}x DISPLAY NAME SPOOFING: {spoof['explanation']}")
else:
print(f"\n {Fore.GREEN}+ No display name spoofing detected")
# ── Authentication ────────────────────────────────────────────────────────
print(f"\n{Fore.CYAN}[*] Email Authentication")
fm = re.search(r"@([\w.\-]+)", from_hdr)
from_domain = fm.group(1) if fm else ""
hops = trace_received_headers(headers)
findings["received_hops"] = hops
orig_ip = hops[0]["ip"] if hops else "unknown"
# The receiving server's own verdict, when it recorded one. Authoritative.
auth_hdr = parse_auth_results(headers)
spf_r = check_spf(from_domain, orig_ip, auth_hdr)
dkim_r = check_dkim(headers, auth_hdr)
dmarc_r = check_dmarc(from_domain, auth_hdr)
if auth_hdr:
print(f" {Fore.CYAN}(using the Authentication-Results header written by "
f"the receiving server)")
findings.update({"spf": spf_r, "dkim": dkim_r, "dmarc": dmarc_r})
def auth_color(r):
risk = r.get("risk")
if risk == "UNKNOWN":
return Fore.YELLOW # could not check - not a failure
if r.get("result") in ("PASS", "FOUND", "KEY_PUBLISHED") or risk == "OK":
return Fore.GREEN
if risk in ("LOW", "MEDIUM"):
return Fore.YELLOW
return Fore.RED
print(f" {auth_color(spf_r)}SPF: {spf_r.get('result','?')} — {spf_r.get('explanation','')}")
print(f" {auth_color(dkim_r)}DKIM: {dkim_r.get('result','?')} — {dkim_r.get('explanation','')}")
print(f" {auth_color(dmarc_r)}DMARC: {dmarc_r.get('result','?')} {dmarc_r.get('explanation','')}")
# ── Received-chain trace ──────────────────────────────────────────────────
print(f"\n{Fore.CYAN}[*] Originating IP Trace (Received headers)")
for hop in hops:
print(f" Hop {hop['hop']}: {hop.get('from_host','?')} -> {hop['ip']}")
if hops:
print(f" {Fore.YELLOW}True origin: {orig_ip}")
# ── Lookalike domain ──────────────────────────────────────────────────────
if from_domain:
lookalike = check_lookalike_domain(from_domain)
findings["lookalike"] = lookalike
if lookalike.get("lookalike") or lookalike.get("suspicious_tld"):
print(f" {Fore.RED}x LOOKALIKE DOMAIN: {lookalike.get('explanation', from_domain)}")
# ── URL analysis ──────────────────────────────────────────────────────────
body = parsed["text_body"] + parsed["html_body"]
urls = extract_urls(body)
print(f"\n{Fore.CYAN}[*] URLs Extracted — {len(urls)} found")
url_results = []
for url in urls[:10]:
res = check_url_threat_intel(url, os.environ.get("VT_API_KEY"))
url_results.append(res)
listed = res.get("urlhaus", {}).get("listed", False)
vt_mal = res.get("virustotal", {}).get("malicious", 0)
if listed or vt_mal >= 2:
print(f" {Fore.RED}x {url[:80]}")
if listed:
print(f" URLhaus: ACTIVE {res['urlhaus'].get('threat','phishing')} site")
if vt_mal:
print(f" VirusTotal: {vt_mal}/{res.get('virustotal',{}).get('total','?')} vendors flagged")
else:
print(f" {Fore.GREEN}+ {url[:80]}")
findings["url_results"] = url_results
# ── Attachment analysis ───────────────────────────────────────────────────
print(f"\n{Fore.CYAN}[*] Attachments — {len(parsed['attachments'])} found")
att_results = []
for att in parsed["attachments"]:
result = analyze_attachment(att)
att_results.append(result)
danger = (result.get("has_macros") or result.get("double_extension")
or result.get("malwarebazaar", {}).get("listed"))
if result.get("double_extension_warning"):
print(f" {Fore.RED}x DOUBLE EXTENSION — {result['double_extension_warning']}")
if result.get("rtl_override_warning"):
print(f" {Fore.RED}x RTL OVERRIDE — {result['rtl_override_warning']}")
color = Fore.RED if danger else Fore.WHITE
print(f" {color}{att['filename']} ({att['size']:,} bytes) | SHA256: {att['sha256'][:16]}...")
if result.get("has_macros"):
print(f" {Fore.RED}x MACROS DETECTED — {result.get('macro_warning','')}")
if result.get("malwarebazaar", {}).get("listed"):
mb = result["malwarebazaar"]
print(f" {Fore.RED}x MalwareBazaar KNOWN MALICIOUS — {mb.get('malware_family','unknown')}")
if args.extract_attachments:
os.makedirs("attachments", exist_ok=True)
out_path = os.path.join("attachments", att["filename"])
Path(out_path).write_bytes(att.get("data", b""))
print(f" {Fore.CYAN}Saved to: {out_path}")
findings["attachment_results"] = att_results
# Strip raw attachment bytes before serializing
for att in parsed["attachments"]:
att.pop("data", None)
# ── Verdict ───────────────────────────────────────────────────────────────
verdict = score_phishing(findings)
findings["verdict"] = verdict
color_v = (Fore.RED if verdict["verdict"] == "MALICIOUS" else
Fore.YELLOW if verdict["verdict"] == "SUSPICIOUS" else Fore.GREEN)
mark = "x" if verdict["verdict"] != "LIKELY_SAFE" else "+"
print(f"\n{color_v}{Style.BRIGHT}Verdict: {mark} {verdict['verdict']} | Confidence: {verdict['confidence_pct']}%")
if verdict["verdict"] == "MALICIOUS":
print(f"{Fore.RED}MITRE ATT&CK: T1566.001 (Spearphishing Attachment), T1204.002 (User Execution: Malicious File)")
print(f"{Fore.YELLOW}Recommended: Block sender domain at email gateway | Submit attachment to sandbox")
# ── Save report ───────────────────────────────────────────────────────────
if verdict.get("reasons"):
print(f"\n{Fore.CYAN}[*] Why:")
for why in verdict["reasons"]:
print(f" {why}")
if verdict.get("undetermined_checks"):
print(f"{Fore.YELLOW} note: {', '.join(verdict['undetermined_checks'])} "
f"could not be checked - verdict is based on incomplete data")
report = {
"file": args.file,
"headers": dict(headers.items()),
"findings": findings,
}
with open(args.output, "w") as f:
json.dump(report, f, indent=2, default=str)
print(f"{Fore.CYAN}[*] Report saved: {args.output}")
return {"MALICIOUS": 2, "SUSPICIOUS": 1}.get(verdict["verdict"], 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--filetextrequiredThe email to analyse, as a .eml file. Save it from your client with headers intact.
--outputtextJSON report path. Default: phishing_report.json.
--extract-attachmentstextSave attachments to ./attachments/. They are written to disk, not opened - see the safety section before using this.
What This Solves
Somebody forwards you an email and asks "is this real?" Answering properly means reading the Received chain backwards to find where it actually entered, checking SPF, DKIM and DMARC yourself rather than trusting the client's green tick, looking at the display name against the real address, checking the sending domain against lookalikes, and examining the attachment without opening it. That is fifteen minutes per email, and the fifth one that week gets three minutes and a guess. This does the whole pass in one command: header forensics the Received chain, traced to the true origin SPF / DKIM / DMARC validated independently, not read from the Authentication-Results header the sender's own infrastructure wrote display spoofing "IT Support <attacker@gmail.com>" lookalike domains rnicrosoft.com and friends URL extraction checked against URLhaus and VirusTotal attachments type identified by content, not by extension and produces a phishing score with the reasons behind it.
Is It Safe to Run - Read This First
Safe, with one flag that needs thought and one privacy consideration. It never opens or executes an attachment. Attachments are identified by their actual content and analysed as data. Nothing is run. --extract-attachments WRITES THEM TO DISK. Into `./attachments/`. The file is not executed, but a malicious document is then sitting on your filesystem where your endpoint protection - or a careless double-click - will meet it. Use it only when you intend to analyse the file, and preferably in a VM or a dedicated analysis host. Without the flag, nothing is written. URL checks send the URLs to third parties. URLhaus and, if `VT_API_KEY` is set, VirusTotal. In a targeted campaign, submitting the URL tells the service - and potentially the attacker watching their own infrastructure - that it was detected. For a commodity phishing run this does not matter. For a targeted one, it may. The email itself is not uploaded. Only the extracted URLs. Headers, body and attachments stay on your machine. It does not visit the URLs. No request is made to the phishing site. The URLs are looked up in reputation services, not fetched - so a tracking link is not triggered and the attacker does not learn your IP.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4" \ "python-magic>=0.4" `python-magic` needs libmagic on the host: `apt install libmagic1` on Debian/Ubuntu, `brew install libmagic` on macOS, and the `python-magic-bin` package on Windows. 3. The email as a `.eml` file, **with headers intact**. In Outlook, drag the message to a folder, or File > Save As > Outlook Message and convert. In Gmail, "Show original" then download. Forwarding the email to yourself destroys the original headers, which are the entire point. 4. Optionally `VT_API_KEY` for the VirusTotal lookups. Without it the URLhaus checks still run.
Credentials
VT_API_KEY VirusTotal API key. Optional - the rest of the analysis runs without it. export VT_API_KEY='your-key' Windows PowerShell: $env:VT_API_KEY = 'your-key' There is deliberately no `--vt-key` flag. An API key on the command line is readable by every user on the machine in `ps auxww` and lands in shell history.
Your First Run
Save the suspicious email as `.eml`, then: python phishing_analyzer.py --file suspicious.eml That is the whole analysis. Nothing is written except the report. Run it once on an email you **know** is legitimate - an internal newsletter, a notification from a service you use. Seeing what a clean result looks like on your own mail flow is what lets you read a real one quickly, and it will also show you whether your own domain's SPF and DKIM are in order. With attachments, only when you mean it: python phishing_analyzer.py --file suspicious.eml \ --extract-attachments --output report.json
What the Output Means
The analysis is sectioned, and ends in a phishing score with the reasons. The parts that decide most cases: The Received chain Read bottom-up: the bottom is where the message entered, the top is your own infrastructure. **Only the hops your own infrastructure added can be trusted** - everything below can be forged by the sender. The useful question is where the message entered a server you control, and what it claimed to be at that point. SPF / DKIM / DMARC, checked independently The script does its own DNS lookups rather than believing the `Authentication-Results` header, which an attacker can write. The three say different things and people conflate them: SPF the sending IP is allowed to send for that domain DKIM the message is signed and unmodified DMARC what to do when SPF and DKIM disagree with the From domain, and - crucially - it is the only one that checks the **visible** From address A message can pass SPF and DKIM for `attacker-domain.com` while displaying `From: billing@yourbank.com`. That is a DMARC failure, and it is the single most common technical shape of a real phish. Display name spoofing `IT Support <attacker@gmail.com>`. Most clients show only the display name on a phone, which is the entire point of the technique. Lookalike domain `rnicrosoft.com`, `arnazon.com`, a Unicode homoglyph. Checked against the registrable domain, so a subdomain trick like `yourbank.com.evil.net` is caught as well. Attachment analysis The real type from the content, not the extension - an `.pdf` that is actually an executable is a conclusive finding on its own. Macro-bearing Office documents and archive contents are flagged. The score A weighted summary, and a starting point rather than a verdict. **A legitimate email can score badly**: marketing mail routed through a third-party sender frequently fails SPF alignment, and a newly registered partner domain looks like a lookalike to a heuristic. Read the reasons, not the number.
Exit Codes for Cron and CI
0 low risk 1 suspicious 2 high confidence phishing 130 you pressed Ctrl-C Usable in an automated mailbox triage - a phishing report address that drops `.eml` files into a directory, each analysed and routed by exit code. Be careful about auto-actioning on the result: the false positives above are common enough that an automatic block on score alone will eventually block a customer.
Every Flag
--file PATH REQUIRED. The email as .eml, with headers intact. --output PATH JSON report. Default phishing_report.json. --extract-attachments Save attachments to ./attachments/. They are written to disk, not opened - see IS IT SAFE TO RUN before using this. `VT_API_KEY` comes from the environment.
When Something Fails
ModuleNotFoundError: magic `pip install "python-magic>=0.4"`, and install libmagic itself - `apt install libmagic1`, `brew install libmagic`, or the `python-magic-bin` package on Windows. This is the most common setup problem with this script. The header analysis is empty or wrong The email was forwarded rather than saved. Forwarding rebuilds the headers and destroys the original Received chain - the only thing that can tell you where the message really came from. Get the original as .eml. SPF fails on a legitimate email Very common for mail routed through a third-party sender - marketing platforms, ticketing systems, anything that sends "as" a domain without being in its SPF record. Check DKIM and DMARC alignment before concluding anything; if DKIM passes and aligns, the message is authentic. DMARC passes but it is obviously a phish DMARC only checks alignment with the From domain. An attacker who owns `yourbank-support.com` and configures it correctly passes DMARC perfectly. Authentication proves the domain sent it, not that the domain is trustworthy - which is why the lookalike check exists. No URL findings on an email full of links URLHaus knows what has been reported, and a campaign launched this morning has not been. Set VT_API_KEY for a second source, and treat "unknown" as unknown rather than clean - fresh infrastructure is unknown by design. A .msg file instead of .eml Convert it first. `.msg` is a compound Outlook format; `.eml` is the raw message, which is what the parser needs. You extracted an attachment and your AV deleted it Working as intended, on both sides. If you need to analyse it, do it on a host set up for that, not on your workstation.