Build a Dual-Engine Phishing Analyzer
Build a Python CLI tool that detects phishing emails with scoring and AI analysis.
Introduction
30 Second Summary
Phishing emails are the number one way attackers break into accounts and steal money. AI has made them even harder to spot because convincing fake messages can now be generated in seconds.
In this project, you will build PhishGuard, a command-line Python tool that detects phishing emails using two detection engines and an optional AI enhancement layer that activates automatically when any API key is available.
What You'll Build
A dual-engine phishing analyzer that extracts email headers, flags suspicious URLs, detects behavioral attack patterns, maps findings to MITRE ATT&CK techniques, and optionally enhances results with AI-powered threat narratives.
By the end of this project, you'll have:
- A dual-engine phishing detection tool that analyzes email headers, URLs, and behavioral patterns to produce a threat score and verdict.
- A provider-agnostic AI enhancement that auto-activates when any compatible API key is set, adding threat narratives and suggested detection rules.
- A set of test email samples covering classic phishing, legitimate messages, and subtle BEC attacks to verify your tool catches real-world patterns.
- Secret Mission: Add batch analysis mode that generates a professional Markdown threat intelligence report with a risk heatmap and aggregated IOCs.
Are there any prerequisites?
None. This guide walks you through installing everything you need. For the optional AI enhancement, any OpenAI-compatible API key works (OpenAI, Groq, Anthropic, Ollama, and others).
Set Up Your Project
PhishGuard uses a dual-engine architecture to detect phishing emails. Each engine handles a different type of analysis, and an optional AI layer adds contextual reasoning on top.
In this step, you will open your project workspace and learn how the three layers work together before writing any code.
In this step, get ready to:
- Open the phishguard folder in VS Code as your workspace.
- Understand the dual-engine architecture (static analysis, behavioral correlation, and optional AI enhancement).
Open the project folder in VS Code
Your phishguard folder and sample_emails subfolder already exist from the previous step. Now you need VS Code to treat phishguard as your active workspace so files appear in the sidebar.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the phishguard folder, and click Open.
The Explorer sidebar on the left should now show the sample_emails folder inside your workspace.
Don't see the sample_emails folder?
Make sure you selected the phishguard folder itself, not the folder that contains it. If you accidentally opened the Desktop, close it and repeat the Open Folder step targeting phishguard directly.
help me find my phishguard folder in VS Code
Understand the dual-engine architecture
Before you start writing code, it helps to know how PhishGuard's three layers fit together. Each layer answers a fundamentally different question about a suspicious email.
- Engine 1 (Static Analysis) extracts technical indicators from email headers and URLs. It answers: WHAT suspicious markers are present?
- Engine 2 (Behavioral Correlation) detects social engineering patterns like brand impersonation and authority pressure. It answers: WHAT attack patterns match?
- AI Enhancement (Optional) receives the structured output of both engines and produces a threat narrative explaining the attacker's strategy. It answers: WHY is this dangerous and HOW do you defend against it?
Why two engines plus optional AI?
The two local engines are fast, deterministic, and private. They never send your email data over the network. The AI layer adds contextual reasoning that pattern-matching fundamentally cannot produce, like explaining why a specific target would fall for an attack.
Enterprise security tools use the same split. Deterministic rules handle known patterns at speed. AI handles novel threats that need contextual reasoning. You are building both.
What is defense-in-depth detection?
Defense-in-depth means layering multiple independent detection methods so that if one misses an attack, another catches it. A BEC email that passes all header checks (Engine 1 finds nothing) can still be flagged by behavioral correlation (Engine 2 spots authority impersonation combined with communication isolation).
Your project folder is ready and you have a clear picture of how the three layers connect. Next up, you will create realistic test emails that exercise every detection engine.
Create Your Test Email Samples
Your environment is ready. Now you need something to analyze.
A phishing detector is only as good as the data you test it against. In this step, you will create three email samples that represent the real-world spectrum: an obvious phishing attempt, a legitimate email, and a subtle BEC (Business Email Compromise) attack that slips past basic header checks.
In this step, get ready to:
- Create a phishing email with multiple red flags (header mismatch, SPF fail, suspicious URL).
- Create a legitimate email to test your tool's false-positive rate.
- Create a subtle BEC spear-phishing attempt that passes header checks but uses social engineering.
Create the phishing email sample
This first sample is a classic phishing email packed with red flags. It impersonates Amazon, uses a mismatched Return-Path domain, fails SPF checks, and pressures the victim with urgency language.
- In VS Code, right-click the sample_emails folder in the Explorer sidebar.
- Click New File.
- Name the file phishing_01.txt and press Enter.
- Paste the following email content into the file:
From: security-team@amaz0n-alerts.com
To: victim@company.com
Subject: Urgent: Your Account Has Been Compromised
Date: Mon, 8 Jun 2026 09:15:32 -0400
Return-Path: <bouncer@cheap-hosting-xyz.net>
Received: from mail.cheap-hosting-xyz.net (192.168.1.100)
by mx.company.com with SMTP id abc123
for <victim@company.com>;
Mon, 8 Jun 2026 09:15:30 -0400
X-SPF: fail (domain amaz0n-alerts.com does not designate 192.168.1.100 as permitted sender)
DKIM-Signature: none
Content-Type: text/html; charset="UTF-8"
Dear Valued Customer,
We have detected unusual activity on your Amazon account. Your account has been temporarily limited until you verify your identity.
Please click the link below immediately to restore access:
http://amaz0n-security-verify.cheap-hosting-xyz.net/login/verify?id=8839201
If you do not verify within 24 hours, your account will be permanently suspended.
Thank you,
Amazon Security Team
---
This email was sent by Amazon.com, Inc. | 410 Terry Avenue North, Seattle, WA 98109
What makes this email phishing?
This sample has multiple indicators that PhishGuard will detect:
- Header mismatch: The From address says amaz0n-alerts.com but the Return-Path says cheap-hosting-xyz.net.
- SPF failure: The sending IP is not authorized to send on behalf of the claimed domain.
- Suspicious URL: The link uses a homoglyph (the zero in "amaz0n") and points to a cheap hosting domain.
- Urgency language: "immediately", "within 24 hours", "permanently suspended".
- Save phishing_01.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- In the VS Code Explorer sidebar, you should see phishing_01.txt listed under the sample_emails folder.
File not appearing in sample_emails?
- Make sure you right-clicked the sample_emails folder (not the root phishguard folder) before clicking New File.
- If the file ended up in the wrong folder, drag it into sample_emails using the Explorer sidebar.
Help me figure out why my file is in the wrong location.
Create the legitimate email sample
A detection tool that flags everything as phishing is useless. You need a legitimate email to test that PhishGuard correctly identifies safe messages without false positives.
- Right-click the sample_emails folder and click New File.
- Name it legitimate_01.txt and press Enter.
- Paste the following email content into the file:
From: noreply@github.com
To: developer@company.com
Subject: [GitHub] A new sign-in to your account
Date: Mon, 8 Jun 2026 14:22:10 -0700
Return-Path: <noreply@github.com>
Received: from o1.sgmail.github.com (167.89.123.45)
by mx.company.com with SMTP id def456
for <developer@company.com>;
Mon, 8 Jun 2026 14:22:08 -0700
X-SPF: pass (domain github.com designates 167.89.123.45 as permitted sender)
DKIM-Signature: v=1; a=rsa-sha256; d=github.com; s=pf2023;
Content-Type: text/plain; charset="UTF-8"
Hi developer,
We noticed a new sign-in to your GitHub account.
Device: Chrome on Windows
Location: New York, US
Time: June 8, 2026 at 2:20 PM EDT
If this was you, no further action is needed.
If this was not you, please review your account security settings at:
https://github.com/settings/security
Thanks,
The GitHub Team
What makes this email legitimate?
- The From address (noreply@github.com) matches the Return-Path exactly.
- SPF passes. The sending server is authorized by the domain.
- DKIM is present with a valid signature.
- The URL (https://github.com/settings/security) points to the real GitHub domain with HTTPS.
- No urgency language or pressure tactics. The tone is calm and informational.
- Save legitimate_01.txt.
- Confirm that legitimate_01.txt appears in the Explorer sidebar under sample_emails.
Saved but don't see the file?
- Check the file extension is .txt and not .txt.txt. VS Code sometimes adds a duplicate extension if you include it in the filename prompt.
- Click the file tab at the top of VS Code to make sure it shows sample_emails/legitimate_01.txt as the path.
Help me find where my file was saved.
Create the BEC spear-phishing sample
This third sample is the most dangerous type of phishing in the real world. A BEC (Business Email Compromise) attack passes all technical header checks because the attacker uses a real, properly configured domain.
Instead of technical spoofing, it relies on social engineering: impersonating an executive, requesting a wire transfer, and discouraging phone verification. This is why you need Engine 2 (behavioral analysis) in addition to Engine 1 (header checks).
- Right-click the sample_emails folder and click New File.
- Name it phishing_02.txt and press Enter.
- Paste the following email content into the file:
From: cfo@company-corp.com
To: accounting@company.com
Subject: Re: Wire Transfer - Q2 Vendor Payment
Date: Mon, 8 Jun 2026 11:45:00 -0400
Return-Path: <cfo@company-corp.com>
Received: from mail.company-corp.com (203.0.113.50)
by mx.company.com with SMTP id ghi789
for <accounting@company.com>;
Mon, 8 Jun 2026 11:44:58 -0400
X-SPF: pass (domain company-corp.com designates 203.0.113.50 as permitted sender)
DKIM-Signature: v=1; a=rsa-sha256; d=company-corp.com; s=mail;
Content-Type: text/plain; charset="UTF-8"
Hi Team,
I need you to process an urgent wire transfer for our Q2 vendor payment. This was discussed in our board meeting last week.
Amount: $47,500
Recipient: GlobalTech Solutions Ltd
Bank: First National Bank, Account: 2847193650
Routing: 071000013
Please process this today before 3 PM EST. I am in back-to-back meetings and cannot take calls, but this needs to go out before the vendor deadline.
Let me know once it is done.
Thanks,
Michael Chen
CFO
Why is this harder to detect?
Notice that the headers are perfectly valid. SPF passes, DKIM is present, and From matches Return-Path. Engine 1 (static analysis) would give this a clean bill of health.
The attack relies entirely on behavioral patterns that only Engine 2 can catch:
- Authority impersonation: Claims to be the CFO.
- Financial request: Wire transfer with bank account and routing numbers.
- Communication isolation: "Cannot take calls" discourages the victim from verifying by phone.
- Urgency: "Process today before 3 PM" pressures immediate action.
- Save phishing_02.txt.
- Confirm all three files by running this in your VS Code terminal:
ls sample_emails/
You should see legitimate_01.txt, phishing_01.txt, and phishing_02.txt listed in the output.
Missing one or more files?
- Make sure your terminal is in the phishguard folder. If not, run cd ~/Desktop/phishguard first.
- If a file appears in the root phishguard folder instead of sample_emails, drag it into the correct folder using the Explorer sidebar.
- Check that every filename ends in .txt with no extra characters or spaces.
Help me fix my sample_emails folder structure.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved and you are ready for the next step.
ⓧ I'd like to double check the full code
Here is the full content of each file for comparison.
From: security-team@amaz0n-alerts.com
To: victim@company.com
Subject: Urgent: Your Account Has Been Compromised
Date: Mon, 8 Jun 2026 09:15:32 -0400
Return-Path: <bouncer@cheap-hosting-xyz.net>
Received: from mail.cheap-hosting-xyz.net (192.168.1.100)
by mx.company.com with SMTP id abc123
for <victim@company.com>;
Mon, 8 Jun 2026 09:15:30 -0400
X-SPF: fail (domain amaz0n-alerts.com does not designate 192.168.1.100 as permitted sender)
DKIM-Signature: none
Content-Type: text/html; charset="UTF-8"
Dear Valued Customer,
We have detected unusual activity on your Amazon account. Your account has been temporarily limited until you verify your identity.
Please click the link below immediately to restore access:
http://amaz0n-security-verify.cheap-hosting-xyz.net/login/verify?id=8839201
If you do not verify within 24 hours, your account will be permanently suspended.
Thank you,
Amazon Security Team
---
This email was sent by Amazon.com, Inc. | 410 Terry Avenue North, Seattle, WA 98109
From: noreply@github.com
To: developer@company.com
Subject: [GitHub] A new sign-in to your account
Date: Mon, 8 Jun 2026 14:22:10 -0700
Return-Path: <noreply@github.com>
Received: from o1.sgmail.github.com (167.89.123.45)
by mx.company.com with SMTP id def456
for <developer@company.com>;
Mon, 8 Jun 2026 14:22:08 -0700
X-SPF: pass (domain github.com designates 167.89.123.45 as permitted sender)
DKIM-Signature: v=1; a=rsa-sha256; d=github.com; s=pf2023;
Content-Type: text/plain; charset="UTF-8"
Hi developer,
We noticed a new sign-in to your GitHub account.
Device: Chrome on Windows
Location: New York, US
Time: June 8, 2026 at 2:20 PM EDT
If this was you, no further action is needed.
If this was not you, please review your account security settings at:
https://github.com/settings/security
Thanks,
The GitHub Team
From: cfo@company-corp.com
To: accounting@company.com
Subject: Re: Wire Transfer - Q2 Vendor Payment
Date: Mon, 8 Jun 2026 11:45:00 -0400
Return-Path: <cfo@company-corp.com>
Received: from mail.company-corp.com (203.0.113.50)
by mx.company.com with SMTP id ghi789
for <accounting@company.com>;
Mon, 8 Jun 2026 11:44:58 -0400
X-SPF: pass (domain company-corp.com designates 203.0.113.50 as permitted sender)
DKIM-Signature: v=1; a=rsa-sha256; d=company-corp.com; s=mail;
Content-Type: text/plain; charset="UTF-8"
Hi Team,
I need you to process an urgent wire transfer for our Q2 vendor payment. This was discussed in our board meeting last week.
Amount: $47,500
Recipient: GlobalTech Solutions Ltd
Bank: First National Bank, Account: 2847193650
Routing: 071000013
Please process this today before 3 PM EST. I am in back-to-back meetings and cannot take calls, but this needs to go out before the vendor deadline.
Let me know once it is done.
Thanks,
Michael Chen
CFO
You now have three test emails covering the full detection spectrum. Next up, you will build Engine 1 to parse these headers and extract the technical indicators hiding in them.
Build Engine 1 - Static Analysis
You now have three realistic email samples ready to analyze. The next step is to build the detection logic that examines these emails for suspicious indicators.
Engine 1 focuses on static analysis. It reads an email file, pulls apart the headers, checks URLs for red flags, and scans for pressure language. Each of these detections is a Python function that returns structured findings your scoring engine can use later.
In this step, get ready to:
- Write a file reader and header parser that extracts authentication signals from email headers.
- Build URL extraction logic that flags suspicious patterns like homoglyph domains and bad TLDs.
- Create a language pattern detector that identifies urgency and pressure tactics.
Write the email file reader and header parser
Before any detection logic can run, the tool needs to open an email file and extract the metadata that lives in its headers. Headers contain authentication records like SPF and DKIM that reveal whether the sender is who they claim to be.
- In VS Code, create a new file called phishguard.py in the root of your phishguard folder (not inside sample_emails/).
- Add the imports and reference data by pasting this code:
import sys
import os
import re
KNOWN_BRANDS = {
"amazon": ["amazon.com", "amazon.co.uk", "amazon.de"],
"microsoft": ["microsoft.com", "outlook.com", "live.com"],
"google": ["google.com", "gmail.com"],
"paypal": ["paypal.com"],
"apple": ["apple.com", "icloud.com"],
"netflix": ["netflix.com"],
"github": ["github.com"],
}
MITRE_MAPPING = {
"header_mismatch": "T1566.001: Phishing: Spearphishing Attachment/Link",
"spf_fail": "T1566.001: Phishing: Spearphishing Link",
"brand_impersonation": "T1566.001: Phishing: Spearphishing Link",
"urgency_language": "T1204.001: User Execution: Malicious Link",
"suspicious_url": "T1566.002: Phishing: Spearphishing Link",
"financial_request": "T1566.003: Phishing: Spearphishing via Service (BEC)",
"authority_impersonation": "T1534: Internal Spearphishing",
"communication_isolation": "T1534: Internal Spearphishing",
}
What does this code do?
The three imports bring in Python's standard library modules. sys handles command-line arguments and exits. os checks file paths. re provides regular expressions for pattern matching.
KNOWN_BRANDS maps brand names to their legitimate domains. When an email mentions "amazon" but comes from a non-Amazon domain, that is a red flag.
MITRE_MAPPING links each detection type to a real MITRE ATT&CK technique ID. This is the same framework enterprise security teams use to classify attacks.
- Save phishguard.py.
You will not see any output yet. The next chunk builds on these definitions.
- Add the file reader function below the MITRE_MAPPING dictionary by pasting this code:
def read_email_file(filepath):
# Check if the file exists before trying to open it
if not os.path.exists(filepath):
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
# Read the entire file content as a string
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Reject empty files
if not content.strip():
print(f"Error: File '{filepath}' is empty.")
sys.exit(1)
return content
What does this code do?
read_email_file() is the entry point for all analysis. It validates that the file exists and is not empty before returning its full text content. If anything is wrong, it prints a clear error and stops the program with sys.exit(1).
- Save phishguard.py.
- Verify the function works by running this in your terminal:
python -c "from phishguard import read_email_file; print(read_email_file('sample_emails/phishing_01.txt')[:80])"
You should see the first 80 characters of your phishing email printed, starting with From: security-team@amaz0n-alerts.com.
Seeing ModuleNotFoundError?
Make sure your terminal is in the phishguard folder (not inside sample_emails/). Run cd ~/Desktop/phishguard to get back to the right location.
Also check that your file is saved as phishguard.py (not phishguard.py.txt or similar).
Help me debug this import error
- Add the header parser function below read_email_file() by pasting this code:
def parse_headers(email_content):
# Initialize a dictionary to hold the header values we care about
headers = {"from": "", "return_path": "", "spf": "", "dkim": "", "subject": ""}
# Headers end at the first blank line in an email
header_section = email_content.split("\n\n")[0]
for line in header_section.split("\n"):
line_lower = line.lower().strip()
if line_lower.startswith("from:"):
headers["from"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("return-path:"):
headers["return_path"] = line.split(":", 1)[1].strip().strip("<>")
elif line_lower.startswith("x-spf:"):
headers["spf"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("dkim-signature:"):
value = line.split(":", 1)[1].strip()
headers["dkim"] = "none" if value.lower() == "none" else "present"
elif line_lower.startswith("subject:"):
headers["subject"] = line.split(":", 1)[1].strip()
return headers
What does this code do?
Email headers live above the first blank line. parse_headers() splits the email at that boundary and loops through each header line to extract five key fields into a dictionary.
The split(":", 1) call splits on only the first colon. This is important because header values can contain colons themselves (like timestamps).
- Save phishguard.py.
- Test the parser by running:
python -c "from phishguard import *; e = read_email_file('sample_emails/phishing_01.txt'); print(parse_headers(e))"
You should see a dictionary with the extracted header values, including 'spf': 'fail ...' and 'dkim': 'none'.
Headers showing empty strings?
Check that your sample_emails/phishing_01.txt file has no extra blank lines before the headers. The parser expects headers to start on line 1.
Help me debug empty header values
- Add the header analysis function below parse_headers() by pasting this code:
def analyze_headers(headers):
findings = []
techniques = []
# Extract the domain from the From address
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
# Extract the domain from the Return-Path address
return_path_domain = headers["return_path"].split("@")[-1] if "@" in headers["return_path"] else ""
# Flag if From and Return-Path domains don't match (common phishing indicator)
if from_domain and return_path_domain and from_domain != return_path_domain:
findings.append(f"HEADER MISMATCH: From domain ({from_domain}) != Return-Path domain ({return_path_domain})")
techniques.append("header_mismatch")
# Flag SPF failures
if "fail" in headers["spf"].lower():
findings.append(f"SPF FAILURE: {headers['spf']}")
techniques.append("spf_fail")
# Flag missing DKIM signatures
if headers["dkim"] == "none":
findings.append("DKIM MISSING: No DKIM signature present")
return findings, techniques
What does this code do?
analyze_headers() takes the parsed dictionary and checks for three authentication failures.
- A From/Return-Path mismatch means the sender's display address does not match where bounced emails actually go. Phishers use this to impersonate brands.
- An SPF failure means the sending server is not authorized to send on behalf of the claimed domain.
- A missing DKIM signature means the email's integrity cannot be cryptographically verified.
Each finding is paired with a technique identifier that maps to MITRE_MAPPING later.
- Save phishguard.py.
- Verify the full header analysis pipeline by running:
python -c "from phishguard import *; e = read_email_file('sample_emails/phishing_01.txt'); h = parse_headers(e); print(analyze_headers(h))"
You should see a tuple containing a list of findings (HEADER MISMATCH and SPF FAILURE) plus a list of technique identifiers.
Only seeing one finding instead of two?
Make sure your phishing_01.txt has both a Return-Path that differs from From AND an X-SPF: fail line in the headers.
Help me debug missing findings
Write URL extraction and analysis logic
Headers tell you if the sender is legitimate. URLs tell you where the attacker wants to send the victim. Phishers hide malicious destinations behind lookalike domains, IP addresses, and cheap TLDs.
- Add the URL extraction function below analyze_headers() by pasting this code:
def extract_urls(email_content):
# Find all URLs starting with http:// or https://
url_pattern = re.compile(r'https?://[^\s<>"]+')
urls = url_pattern.findall(email_content)
findings = []
techniques = []
for url in urls:
issues = []
url_lower = url.lower()
# Flag URLs that use an IP address instead of a domain name
if re.match(r'https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', url):
issues.append("contains IP address instead of domain")
# Check for homoglyph characters that impersonate real letters
for fake, real in {"0": "o", "1": "l", "vv": "w"}.items():
if fake in url_lower:
issues.append(f"possible homoglyph: '{fake}' may impersonate '{real}'")
# Flag known suspicious top-level domains
for tld in [".xyz", ".top", ".buzz", ".click", ".loan"]:
if tld in url_lower:
issues.append(f"suspicious TLD: {tld}")
# Only report URLs that have at least one issue
if issues:
findings.append(f"SUSPICIOUS URL: {url} ({', '.join(issues)})")
techniques.append("suspicious_url")
return findings, techniques, urls
What does this code do?
extract_urls() uses a regular expression to pull every URL from the email. Then it runs three checks on each one.
- IP address check: legitimate services use domain names. A raw IP in a URL is suspicious.
- Homoglyph check: attackers swap letters for lookalikes (0 for o, 1 for l) to create fake domains like amaz0n.
- TLD check: domains ending in .xyz, .top, or .buzz are cheap to register and commonly used in phishing campaigns.
- Save phishguard.py.
- Test URL extraction against the phishing sample by running:
python -c "from phishguard import *; e = read_email_file('sample_emails/phishing_01.txt'); findings, _, _ = extract_urls(e); print(findings)"
You should see a finding about the suspicious URL containing a homoglyph (the 0 in amaz0n).
Getting an empty list?
Check that your phishing_01.txt contains a URL starting with http:// or https://. The regex only matches URLs with a protocol prefix.
Help me debug empty URL results
Write the urgency language detector
Phishing emails use time pressure to stop victims from thinking clearly. Phrases like "verify within 24 hours" or "your account will be permanently suspended" are designed to trigger panic. This function catches those patterns.
- Add the urgency language detector below extract_urls() by pasting this code:
def detect_urgency_language(email_content):
# Regex patterns that match common pressure/urgency phrases
urgency_patterns = [
r"immediately", r"urgent", r"within \d+ hours",
r"account.{0,20}suspend", r"account.{0,20}limited",
r"verify.{0,20}identity", r"permanently.{0,20}(closed|suspended|deleted)",
r"act now", r"cannot take calls", r"before.{0,20}deadline", r"process.{0,20}today",
]
# Only scan the body (everything after the first blank line)
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
matched = []
for pattern in urgency_patterns:
matches = re.findall(pattern, body, re.IGNORECASE)
if matches:
matched.extend(matches)
findings = []
techniques = []
if matched:
findings.append(f"URGENCY LANGUAGE: {len(matched)} pressure indicator(s): {', '.join(matched[:5])}")
techniques.append("urgency_language")
return findings, techniques, len(matched)
What does this code do?
detect_urgency_language() splits the email at the first blank line to isolate the body. It then tests each regex pattern against the body text, collecting all matches.
The patterns use flexible spacing (.{0,20}) to catch variations like "account has been suspended" or "account will be limited". The function returns up to 5 matched phrases so the report stays readable.
- Save phishguard.py.
- Test the urgency detector on your phishing sample by running:
python -c "from phishguard import *; e = read_email_file('sample_emails/phishing_01.txt'); print(detect_urgency_language(e))"
You should see multiple urgency indicators detected, including matches for "immediately", "within 24 hours", and "permanently suspended".
Seeing fewer matches than expected?
Some patterns use capturing groups (like (closed|suspended|deleted)). When re.findall encounters a capturing group, it returns only the captured portion, not the full match. The count is still correct even if the displayed text looks shorter than the full phrase.
Help me understand my regex results
Why test against the legitimate email too?
A good detector does not just catch phishing. It also avoids false positives on legitimate emails. Try running the same test against legitimate_01.txt to confirm it returns zero findings. A tool that flags everything is useless.
✔️ Awesome, I've got everything!
Great. Double check you have saved phishguard.py with all six functions (plus imports and two dictionaries at the top).
ⓧ I'd like to double check the full code
Here is the complete phishguard.py as it should look at the end of this step:
import sys
import os
import re
KNOWN_BRANDS = {
"amazon": ["amazon.com", "amazon.co.uk", "amazon.de"],
"microsoft": ["microsoft.com", "outlook.com", "live.com"],
"google": ["google.com", "gmail.com"],
"paypal": ["paypal.com"],
"apple": ["apple.com", "icloud.com"],
"netflix": ["netflix.com"],
"github": ["github.com"],
}
MITRE_MAPPING = {
"header_mismatch": "T1566.001: Phishing: Spearphishing Attachment/Link",
"spf_fail": "T1566.001: Phishing: Spearphishing Link",
"brand_impersonation": "T1566.001: Phishing: Spearphishing Link",
"urgency_language": "T1204.001: User Execution: Malicious Link",
"suspicious_url": "T1566.002: Phishing: Spearphishing Link",
"financial_request": "T1566.003: Phishing: Spearphishing via Service (BEC)",
"authority_impersonation": "T1534: Internal Spearphishing",
"communication_isolation": "T1534: Internal Spearphishing",
}
def read_email_file(filepath):
if not os.path.exists(filepath):
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
print(f"Error: File '{filepath}' is empty.")
sys.exit(1)
return content
def parse_headers(email_content):
headers = {"from": "", "return_path": "", "spf": "", "dkim": "", "subject": ""}
header_section = email_content.split("\n\n")[0]
for line in header_section.split("\n"):
line_lower = line.lower().strip()
if line_lower.startswith("from:"):
headers["from"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("return-path:"):
headers["return_path"] = line.split(":", 1)[1].strip().strip("<>")
elif line_lower.startswith("x-spf:"):
headers["spf"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("dkim-signature:"):
value = line.split(":", 1)[1].strip()
headers["dkim"] = "none" if value.lower() == "none" else "present"
elif line_lower.startswith("subject:"):
headers["subject"] = line.split(":", 1)[1].strip()
return headers
def analyze_headers(headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
return_path_domain = headers["return_path"].split("@")[-1] if "@" in headers["return_path"] else ""
if from_domain and return_path_domain and from_domain != return_path_domain:
findings.append(f"HEADER MISMATCH: From domain ({from_domain}) != Return-Path domain ({return_path_domain})")
techniques.append("header_mismatch")
if "fail" in headers["spf"].lower():
findings.append(f"SPF FAILURE: {headers['spf']}")
techniques.append("spf_fail")
if headers["dkim"] == "none":
findings.append("DKIM MISSING: No DKIM signature present")
return findings, techniques
def extract_urls(email_content):
url_pattern = re.compile(r'https?://[^\s<>"]+')
urls = url_pattern.findall(email_content)
findings = []
techniques = []
for url in urls:
issues = []
url_lower = url.lower()
if re.match(r'https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', url):
issues.append("contains IP address instead of domain")
for fake, real in {"0": "o", "1": "l", "vv": "w"}.items():
if fake in url_lower:
issues.append(f"possible homoglyph: '{fake}' may impersonate '{real}'")
for tld in [".xyz", ".top", ".buzz", ".click", ".loan"]:
if tld in url_lower:
issues.append(f"suspicious TLD: {tld}")
if issues:
findings.append(f"SUSPICIOUS URL: {url} ({', '.join(issues)})")
techniques.append("suspicious_url")
return findings, techniques, urls
def detect_urgency_language(email_content):
urgency_patterns = [
r"immediately", r"urgent", r"within \d+ hours",
r"account.{0,20}suspend", r"account.{0,20}limited",
r"verify.{0,20}identity", r"permanently.{0,20}(closed|suspended|deleted)",
r"act now", r"cannot take calls", r"before.{0,20}deadline", r"process.{0,20}today",
]
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
matched = []
for pattern in urgency_patterns:
matches = re.findall(pattern, body, re.IGNORECASE)
if matches:
matched.extend(matches)
findings = []
techniques = []
if matched:
findings.append(f"URGENCY LANGUAGE: {len(matched)} pressure indicator(s): {', '.join(matched[:5])}")
techniques.append("urgency_language")
return findings, techniques, len(matched)
Engine 1 is complete. You now have functions that can read an email, parse its headers, check authentication records, flag suspicious URLs, and detect pressure language. Next up, you will build Engine 2 to catch the behavioral patterns that slip past these technical checks.
Build Engine 2 - Behavioral Correlation
Engine 1 can spot technical red flags like forged headers and suspicious URLs. But the most dangerous phishing attacks pass every technical check. They come from properly configured domains with valid SPF and DKIM signatures.
Engine 2 focuses on behavioral patterns instead. It detects brand impersonation, financial fraud tactics, authority abuse, and communication isolation. These are the hallmarks of Business Email Compromise (BEC) attacks that cost organizations billions every year.
In this step, get ready to:
- Build a brand impersonation detector that cross-references sender domains against known brands.
- Build a financial and BEC pattern detector that identifies wire transfers, authority claims, and isolation tactics.
- Build a weighted scoring algorithm that correlates all findings into a unified threat verdict.
Build the brand impersonation detector
Phishing emails frequently mention trusted brands like Amazon or PayPal while sending from completely unrelated domains. This function checks whether the email content mentions a known brand but the sender domain does not match any of that brand's legitimate domains.
- In phishguard.py, scroll to the bottom of the file (below detect_urgency_language) and add the brand impersonation function:
def detect_brand_impersonation(email_content, headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
full_text = email_content.lower()
for brand, legit_domains in KNOWN_BRANDS.items():
if brand in full_text and from_domain not in legit_domains and from_domain:
findings.append(f"BRAND IMPERSONATION: Mentions '{brand}' but sent from '{from_domain}' (legit: {', '.join(legit_domains)})")
techniques.append("brand_impersonation")
break
return findings, techniques
What does this code do?
This function extracts the sender's domain and then searches the entire email text for mentions of known brand names (using the KNOWN_BRANDS dictionary you defined earlier).
If the email mentions "amazon" but was sent from amaz0n-alerts.com (which is not in Amazon's legitimate domain list), the function flags it as brand impersonation. The break stops after the first brand match to avoid duplicate findings.
- Save phishguard.py.
- You won't see a change yet. The next function builds the BEC detector, and then the scoring algorithm brings both together.
Seeing a syntax error on save?
- Check the f-string on the findings.append line. It uses single quotes inside the f-string for the brand and domain values, which is correct because the f-string itself uses double quotes.
- Make sure the function is at the same indentation level as your other functions (no extra tabs or spaces before def).
Why is my brand impersonation function not working?
Build the financial and BEC pattern detector
BEC attacks are the most financially damaging form of phishing. They combine financial requests with authority impersonation and a clever trick: isolating the victim from verification channels ("I'm in meetings, don't call me").
- Below your detect_brand_impersonation function, add the financial BEC detector:
def detect_financial_bec(email_content):
findings = []
techniques = []
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
body_lower = body.lower()
financial_patterns = [r"wire transfer", r"bank.{0,10}account", r"routing.{0,5}\d{9}", r"\$[\d,]+", r"process.{0,20}payment"]
matches = [p for p in financial_patterns if re.search(p, body, re.IGNORECASE)]
if len(matches) >= 2:
findings.append(f"FINANCIAL REQUEST: {len(matches)} financial indicators detected")
techniques.append("financial_request")
for title in ["ceo", "cfo", "cto", "president", "director", "vp"]:
if title in body_lower:
findings.append(f"AUTHORITY IMPERSONATION: References executive title '{title.upper()}'")
techniques.append("authority_impersonation")
break
for pattern in [r"cannot take calls", r"in.{0,10}meetings", r"do not.{0,10}call", r"reply.{0,10}email only"]:
if re.search(pattern, body, re.IGNORECASE):
findings.append("COMMUNICATION ISOLATION: Sender discourages phone verification")
techniques.append("communication_isolation")
break
return findings, techniques
What does this code do?
This function runs three separate checks on the email body. First, it looks for financial language (wire transfers, bank accounts, dollar amounts, routing numbers). It requires at least 2 matches to flag, reducing false positives from emails that casually mention money.
Second, it checks for executive title claims (CEO, CFO, etc.). Third, it detects communication isolation tactics where the sender actively discourages the victim from verifying the request by phone. The combination of all three is a classic BEC fingerprint.
- Save phishguard.py.
- You won't see a change yet. The scoring algorithm in the next substep brings both detectors together for a unified verdict.
Getting a regex error?
- Make sure the regex patterns use raw strings (the r prefix before the quotes). Without it, backslashes get interpreted as escape characters.
- Check the financial_patterns list. Each pattern should start with r" and the dollar sign pattern needs a backslash: r"\$[\d,]+".
Help me fix my regex patterns in the BEC detector.
Build the weighted scoring algorithm and engine orchestrators
Individual findings are useful, but a security analyst needs a single verdict. The scoring algorithm assigns different weights to each technique (brand impersonation is weighted higher than urgency language, for example) and produces a final score, confidence level, and verdict.
- Below your detect_financial_bec function, add the scoring algorithm:
def calculate_threat_score(engine1_findings, engine2_findings, all_techniques):
weights = {
"header_mismatch": 2.0, "spf_fail": 2.5, "brand_impersonation": 3.0,
"suspicious_url": 2.5, "urgency_language": 1.5, "financial_request": 3.0,
"authority_impersonation": 2.0, "communication_isolation": 2.5,
}
raw_score = sum(weights.get(t, 1.0) for t in all_techniques)
score = min(int(raw_score), 10)
total = len(engine1_findings) + len(engine2_findings)
confidence = "HIGH" if total >= 5 else "MEDIUM" if total >= 3 else "LOW"
if score >= 8 and confidence == "HIGH":
verdict = "CONFIRMED PHISHING"
elif score >= 7:
verdict = "LIKELY PHISHING"
elif score >= 4:
verdict = "SUSPICIOUS"
else:
verdict = "LIKELY SAFE"
return score, confidence, verdict
How does the scoring work?
Each detection technique has a weight reflecting its severity. Brand impersonation and financial requests score 3.0 (strong phishing signals) while urgency language scores only 1.5 (common in legitimate marketing too). The raw score is capped at 10.
Confidence is based on the total number of findings across both engines. More findings means more data points, which means higher confidence in the verdict. A score of 8+ with HIGH confidence triggers a "CONFIRMED PHISHING" verdict.
- Save phishguard.py.
- You won't see a change yet. The engine orchestrator functions below will wire everything together for testing.
Now you need two short orchestrator functions that run each engine's detectors and bundle the results into a clean dictionary.
- Below calculate_threat_score, add both engine orchestrator functions:
def run_engine1(email_content):
headers = parse_headers(email_content)
h_findings, h_tech = analyze_headers(headers)
u_findings, u_tech, _ = extract_urls(email_content)
l_findings, l_tech, _ = detect_urgency_language(email_content)
return {"findings": h_findings + u_findings + l_findings, "techniques": h_tech + u_tech + l_tech, "headers": headers}
def run_engine2(email_content, headers):
b_findings, b_tech = detect_brand_impersonation(email_content, headers)
f_findings, f_tech = detect_financial_bec(email_content)
return {"findings": b_findings + f_findings, "techniques": b_tech + f_tech}
What do the orchestrators do?
These are convenience functions that call all the detectors for each engine and combine their outputs. run_engine1 handles static analysis (headers, URLs, urgency language). run_engine2 handles behavioral correlation (brand impersonation, financial BEC).
Notice that run_engine2 takes the parsed headers from Engine 1 as input. This is intentional. Engine 2 needs the sender domain (from the headers) to check for brand impersonation, but it does not re-parse the email.
- Save phishguard.py.
- Verify your code loads without errors by running this command in the terminal:
python -c "import phishguard; print('PhishGuard loaded successfully')"
- You should see PhishGuard loaded successfully printed in the terminal. If you see an error instead, check for missing colons, mismatched quotes, or indentation problems in the functions you just added.
Seeing an ImportError or SyntaxError?
- Make sure your terminal is in the phishguard folder (not inside sample_emails). Run cd ~/Desktop/phishguard if needed.
- A SyntaxError will show the line number. Jump to that line in VS Code and look for missing colons after def lines, unmatched parentheses, or incorrect indentation.
- Check that every function definition starts at column 0 (no leading spaces before def).
Help me fix my PhishGuard import error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved phishguard.py with all five new functions.
ⓧ I'd like to double check the full code
Here is the complete phishguard.py so far. Compare it against your file:
import sys
import os
import re
KNOWN_BRANDS = {
"amazon": ["amazon.com", "amazon.co.uk", "amazon.de"],
"microsoft": ["microsoft.com", "outlook.com", "live.com"],
"google": ["google.com", "gmail.com"],
"paypal": ["paypal.com"],
"apple": ["apple.com", "icloud.com"],
"netflix": ["netflix.com"],
"github": ["github.com"],
}
MITRE_MAPPING = {
"header_mismatch": "T1566.001: Phishing: Spearphishing Attachment/Link",
"spf_fail": "T1566.001: Phishing: Spearphishing Link",
"brand_impersonation": "T1566.001: Phishing: Spearphishing Link",
"urgency_language": "T1204.001: User Execution: Malicious Link",
"suspicious_url": "T1566.002: Phishing: Spearphishing Link",
"financial_request": "T1566.003: Phishing: Spearphishing via Service (BEC)",
"authority_impersonation": "T1534: Internal Spearphishing",
"communication_isolation": "T1534: Internal Spearphishing",
}
def read_email_file(filepath):
if not os.path.exists(filepath):
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
print(f"Error: File '{filepath}' is empty.")
sys.exit(1)
return content
def parse_headers(email_content):
headers = {"from": "", "return_path": "", "spf": "", "dkim": "", "subject": ""}
header_section = email_content.split("\n\n")[0]
for line in header_section.split("\n"):
line_lower = line.lower().strip()
if line_lower.startswith("from:"):
headers["from"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("return-path:"):
headers["return_path"] = line.split(":", 1)[1].strip().strip("<>")
elif line_lower.startswith("x-spf:"):
headers["spf"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("dkim-signature:"):
value = line.split(":", 1)[1].strip()
headers["dkim"] = "none" if value.lower() == "none" else "present"
elif line_lower.startswith("subject:"):
headers["subject"] = line.split(":", 1)[1].strip()
return headers
def analyze_headers(headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
return_path_domain = headers["return_path"].split("@")[-1] if "@" in headers["return_path"] else ""
if from_domain and return_path_domain and from_domain != return_path_domain:
findings.append(f"HEADER MISMATCH: From domain ({from_domain}) != Return-Path domain ({return_path_domain})")
techniques.append("header_mismatch")
if "fail" in headers["spf"].lower():
findings.append(f"SPF FAILURE: {headers['spf']}")
techniques.append("spf_fail")
if headers["dkim"] == "none":
findings.append("DKIM MISSING: No DKIM signature present")
return findings, techniques
def extract_urls(email_content):
url_pattern = re.compile(r'https?://[^\s<>"]+')
urls = url_pattern.findall(email_content)
findings = []
techniques = []
for url in urls:
issues = []
url_lower = url.lower()
if re.match(r'https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', url):
issues.append("contains IP address instead of domain")
for fake, real in {"0": "o", "1": "l", "vv": "w"}.items():
if fake in url_lower:
issues.append(f"possible homoglyph: '{fake}' may impersonate '{real}'")
for tld in [".xyz", ".top", ".buzz", ".click", ".loan"]:
if tld in url_lower:
issues.append(f"suspicious TLD: {tld}")
if issues:
findings.append(f"SUSPICIOUS URL: {url} ({', '.join(issues)})")
techniques.append("suspicious_url")
return findings, techniques, urls
def detect_urgency_language(email_content):
urgency_patterns = [
r"immediately", r"urgent", r"within \d+ hours",
r"account.{0,20}suspend", r"account.{0,20}limited",
r"verify.{0,20}identity", r"permanently.{0,20}(closed|suspended|deleted)",
r"act now", r"cannot take calls", r"before.{0,20}deadline", r"process.{0,20}today",
]
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
matched = []
for pattern in urgency_patterns:
matches = re.findall(pattern, body, re.IGNORECASE)
if matches:
matched.extend(matches)
findings = []
techniques = []
if matched:
findings.append(f"URGENCY LANGUAGE: {len(matched)} pressure indicator(s): {', '.join(matched[:5])}")
techniques.append("urgency_language")
return findings, techniques, len(matched)
def detect_brand_impersonation(email_content, headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
full_text = email_content.lower()
for brand, legit_domains in KNOWN_BRANDS.items():
if brand in full_text and from_domain not in legit_domains and from_domain:
findings.append(f"BRAND IMPERSONATION: Mentions '{brand}' but sent from '{from_domain}' (legit: {', '.join(legit_domains)})")
techniques.append("brand_impersonation")
break
return findings, techniques
def detect_financial_bec(email_content):
findings = []
techniques = []
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
body_lower = body.lower()
financial_patterns = [r"wire transfer", r"bank.{0,10}account", r"routing.{0,5}\d{9}", r"\$[\d,]+", r"process.{0,20}payment"]
matches = [p for p in financial_patterns if re.search(p, body, re.IGNORECASE)]
if len(matches) >= 2:
findings.append(f"FINANCIAL REQUEST: {len(matches)} financial indicators detected")
techniques.append("financial_request")
for title in ["ceo", "cfo", "cto", "president", "director", "vp"]:
if title in body_lower:
findings.append(f"AUTHORITY IMPERSONATION: References executive title '{title.upper()}'")
techniques.append("authority_impersonation")
break
for pattern in [r"cannot take calls", r"in.{0,10}meetings", r"do not.{0,10}call", r"reply.{0,10}email only"]:
if re.search(pattern, body, re.IGNORECASE):
findings.append("COMMUNICATION ISOLATION: Sender discourages phone verification")
techniques.append("communication_isolation")
break
return findings, techniques
def calculate_threat_score(engine1_findings, engine2_findings, all_techniques):
weights = {
"header_mismatch": 2.0, "spf_fail": 2.5, "brand_impersonation": 3.0,
"suspicious_url": 2.5, "urgency_language": 1.5, "financial_request": 3.0,
"authority_impersonation": 2.0, "communication_isolation": 2.5,
}
raw_score = sum(weights.get(t, 1.0) for t in all_techniques)
score = min(int(raw_score), 10)
total = len(engine1_findings) + len(engine2_findings)
confidence = "HIGH" if total >= 5 else "MEDIUM" if total >= 3 else "LOW"
if score >= 8 and confidence == "HIGH":
verdict = "CONFIRMED PHISHING"
elif score >= 7:
verdict = "LIKELY PHISHING"
elif score >= 4:
verdict = "SUSPICIOUS"
else:
verdict = "LIKELY SAFE"
return score, confidence, verdict
def run_engine1(email_content):
headers = parse_headers(email_content)
h_findings, h_tech = analyze_headers(headers)
u_findings, u_tech, _ = extract_urls(email_content)
l_findings, l_tech, _ = detect_urgency_language(email_content)
return {"findings": h_findings + u_findings + l_findings, "techniques": h_tech + u_tech + l_tech, "headers": headers}
def run_engine2(email_content, headers):
b_findings, b_tech = detect_brand_impersonation(email_content, headers)
f_findings, f_tech = detect_financial_bec(email_content)
return {"findings": b_findings + f_findings, "techniques": b_tech + f_tech}
Why weighted scoring instead of simple counting?
A simple count treats all indicators equally. But a failed SPF check is a much stronger phishing signal than urgency language alone (marketing emails use urgency too). Weighted scoring lets you rank indicators by their diagnostic value, just like real security tools do.
This approach maps directly to the MITRE ATT&CK framework, where different techniques carry different risk levels depending on context.
Both engines are built and the scoring algorithm is ready to produce verdicts. Next up, you will add an optional AI enhancement layer that provides threat narratives no rule-based system can generate on its own.
Build the Optional AI Enhancement Layer
Your dual engines now tell you WHAT is suspicious about an email. Static Analysis flags technical indicators. Behavioral Correlation surfaces attack patterns.
In this step, you will add an optional AI layer that answers a fundamentally different question: WHY is this email dangerous, and HOW should you defend next time? This layer auto-activates when any compatible API key is available and gracefully disappears when one is not.
In this step, get ready to:
- Write a provider detection function that checks for an API key in environment variables.
- Write the AI analysis function that sends structured findings to an LLM for threat narrative generation.
- Design the graceful degradation so the tool works perfectly with or without AI.
Write the provider detection function
Before calling any AI provider, the tool needs to know if a key is even available. The openai Python package reads its key from the OPENAI_API_KEY environment variable automatically.
Your detection function will check for this variable and return a simple boolean. If no key is set, the tool skips AI entirely with no error.
- In phishguard.py, scroll to the bottom of the file (below run_engine2) and add the following function:
def check_ai_available():
"""Check if any supported AI API key is configured."""
if os.environ.get("OPENAI_API_KEY"):
return True
return False
What does this code do?
This function uses os.environ.get() to check whether the OPENAI_API_KEY environment variable exists and has a value.
If the variable is set, the function returns True (AI is available). If not, it returns False (run in local-only mode). No crash, no warning, just a clean decision point.
Why a single environment variable for multiple providers?
The openai Python package has become the universal interface for LLM APIs. OpenAI, Groq, Anthropic, Ollama, and many other providers all accept the same request format.
Users point the package at different providers by setting OPENAI_BASE_URL to their provider's endpoint. One package, one key variable, any provider. Zero lock-in.
- Save phishguard.py.
Write the AI analysis function
The AI function receives the structured output from both engines and asks the LLM to provide what pattern-matching fundamentally cannot: a threat narrative explaining the attacker's strategy, a sophistication rating, victim vulnerability analysis, and a suggested detection rule.
- In phishguard.py, add this function directly below check_ai_available():
def run_ai_enhancement(email_content, engine1, engine2, score, verdict):
"""Run optional AI enhancement for threat narrative analysis."""
try:
from openai import OpenAI
except ImportError:
return None
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return None
base_url = os.environ.get("OPENAI_BASE_URL")
model = os.environ.get("PHISHGUARD_MODEL", "gpt-4o-mini")
client_kwargs = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
findings_text = "\n".join(f"- {f}" for f in engine1["findings"] + engine2["findings"])
if not findings_text:
findings_text = "- No suspicious indicators detected by local engines"
prompt = f"""You are a senior threat analyst. The local detection engines have already identified the following indicators in this email:
LOCAL DETECTION RESULTS:
Threat Score: {score}/10
Verdict: {verdict}
Findings:
{findings_text}
EMAIL CONTENT:
{email_content}
Provide ONLY what pattern-matching cannot: contextual reasoning about this email.
Respond in this exact format:
THREAT NARRATIVE:
[2-3 sentences explaining the attacker's strategy and why this specific approach might fool the target]
SOPHISTICATION: [Amateur / Intermediate / Advanced]
VICTIM VULNERABILITY:
[1-2 sentences on what makes the target likely to fall for this, or why they would not]
SUGGESTED DETECTION RULE:
[One specific regex pattern or heuristic that would catch THIS attack variant in the future, written as a description a security engineer could implement]"""
try:
client = OpenAI(**client_kwargs)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=500,
)
return response.choices[0].message.content
except Exception as e:
return f"[AI Enhancement Error: {e}]"
What does this code do?
- try: from openai import OpenAI attempts to import the package at runtime. If it is not installed, the function immediately returns None instead of crashing.
- OPENAI_BASE_URL lets users point the same package at any compatible provider (Groq, Anthropic, Ollama, etc.) without changing the code.
- PHISHGUARD_MODEL defaults to gpt-4o-mini but users can override it with any model their provider offers.
- The prompt feeds the AI the structured findings from both engines and asks for contextual reasoning that rules cannot produce: threat narrative, sophistication assessment, victim vulnerability, and a suggested detection rule.
- The outer try/except catches any API errors (network issues, invalid key, rate limits) and returns a readable error string instead of crashing.
- Save phishguard.py.
Seeing a red underline on the openai import?
If VS Code shows a warning on from openai import OpenAI, that is expected if you have not installed the openai package. The try/except ImportError block handles this gracefully at runtime.
If you want to install it, run pip install openai in your terminal. This is optional for the base tool to work.
Why is my AI function not getting called?
Understand the graceful degradation design
Your AI layer now has three levels of graceful fallback built in. This is what makes the tool usable by everyone, not just people with API keys.
- If the openai package is not installed, run_ai_enhancement returns None immediately.
- If the package is installed but OPENAI_API_KEY is not set, the function also returns None.
- If the API call fails (bad key, network error, rate limit), it returns an error string instead of crashing.
Why not crash when AI is unavailable?
Enterprise security tools work exactly this way. Deterministic engines handle known patterns at speed. AI handles novel threats that need contextual reasoning. Neither mode makes the other obsolete.
By returning None when AI is unavailable, the display function in the next step can simply check for None and show a helpful message like "Set OPENAI_API_KEY to enable AI threat narrative" instead of an error.
✔️ Awesome, I've got everything!
Great. Double check you have saved phishguard.py with both new functions at the bottom of the file.
ⓧ I'd like to double check the full code
Here is the complete phishguard.py file as it should look at the end of this step:
import sys
import os
import re
KNOWN_BRANDS = {
"amazon": ["amazon.com", "amazon.co.uk", "amazon.de"],
"microsoft": ["microsoft.com", "outlook.com", "live.com"],
"google": ["google.com", "gmail.com"],
"paypal": ["paypal.com"],
"apple": ["apple.com", "icloud.com"],
"netflix": ["netflix.com"],
"github": ["github.com"],
}
MITRE_MAPPING = {
"header_mismatch": "T1566.001: Phishing: Spearphishing Attachment/Link",
"spf_fail": "T1566.001: Phishing: Spearphishing Link",
"brand_impersonation": "T1566.001: Phishing: Spearphishing Link",
"urgency_language": "T1204.001: User Execution: Malicious Link",
"suspicious_url": "T1566.002: Phishing: Spearphishing Link",
"financial_request": "T1566.003: Phishing: Spearphishing via Service (BEC)",
"authority_impersonation": "T1534: Internal Spearphishing",
"communication_isolation": "T1534: Internal Spearphishing",
}
def read_email_file(filepath):
if not os.path.exists(filepath):
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
print(f"Error: File '{filepath}' is empty.")
sys.exit(1)
return content
def parse_headers(email_content):
headers = {"from": "", "return_path": "", "spf": "", "dkim": "", "subject": ""}
header_section = email_content.split("\n\n")[0]
for line in header_section.split("\n"):
line_lower = line.lower().strip()
if line_lower.startswith("from:"):
headers["from"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("return-path:"):
headers["return_path"] = line.split(":", 1)[1].strip().strip("<>")
elif line_lower.startswith("x-spf:"):
headers["spf"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("dkim-signature:"):
value = line.split(":", 1)[1].strip()
headers["dkim"] = "none" if value.lower() == "none" else "present"
elif line_lower.startswith("subject:"):
headers["subject"] = line.split(":", 1)[1].strip()
return headers
def analyze_headers(headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
return_path_domain = headers["return_path"].split("@")[-1] if "@" in headers["return_path"] else ""
if from_domain and return_path_domain and from_domain != return_path_domain:
findings.append(f"HEADER MISMATCH: From domain ({from_domain}) != Return-Path domain ({return_path_domain})")
techniques.append("header_mismatch")
if "fail" in headers["spf"].lower():
findings.append(f"SPF FAILURE: {headers['spf']}")
techniques.append("spf_fail")
if headers["dkim"] == "none":
findings.append("DKIM MISSING: No DKIM signature present")
return findings, techniques
def extract_urls(email_content):
url_pattern = re.compile(r'https?://[^\s<>"]+')
urls = url_pattern.findall(email_content)
findings = []
techniques = []
for url in urls:
issues = []
url_lower = url.lower()
if re.match(r'https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', url):
issues.append("contains IP address instead of domain")
for fake, real in {"0": "o", "1": "l", "vv": "w"}.items():
if fake in url_lower:
issues.append(f"possible homoglyph: '{fake}' may impersonate '{real}'")
for tld in [".xyz", ".top", ".buzz", ".click", ".loan"]:
if tld in url_lower:
issues.append(f"suspicious TLD: {tld}")
if issues:
findings.append(f"SUSPICIOUS URL: {url} ({', '.join(issues)})")
techniques.append("suspicious_url")
return findings, techniques, urls
def detect_urgency_language(email_content):
urgency_patterns = [
r"immediately", r"urgent", r"within \d+ hours",
r"account.{0,20}suspend", r"account.{0,20}limited",
r"verify.{0,20}identity", r"permanently.{0,20}(closed|suspended|deleted)",
r"act now", r"cannot take calls", r"before.{0,20}deadline", r"process.{0,20}today",
]
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
matched = []
for pattern in urgency_patterns:
matches = re.findall(pattern, body, re.IGNORECASE)
if matches:
matched.extend(matches)
findings = []
techniques = []
if matched:
findings.append(f"URGENCY LANGUAGE: {len(matched)} pressure indicator(s): {', '.join(matched[:5])}")
techniques.append("urgency_language")
return findings, techniques, len(matched)
def detect_brand_impersonation(email_content, headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
full_text = email_content.lower()
for brand, legit_domains in KNOWN_BRANDS.items():
if brand in full_text and from_domain not in legit_domains and from_domain:
findings.append(f"BRAND IMPERSONATION: Mentions '{brand}' but sent from '{from_domain}' (legit: {', '.join(legit_domains)})")
techniques.append("brand_impersonation")
break
return findings, techniques
def detect_financial_bec(email_content):
findings = []
techniques = []
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
body_lower = body.lower()
financial_patterns = [r"wire transfer", r"bank.{0,10}account", r"routing.{0,5}\d{9}", r"\$[\d,]+", r"process.{0,20}payment"]
matches = [p for p in financial_patterns if re.search(p, body, re.IGNORECASE)]
if len(matches) >= 2:
findings.append(f"FINANCIAL REQUEST: {len(matches)} financial indicators detected")
techniques.append("financial_request")
for title in ["ceo", "cfo", "cto", "president", "director", "vp"]:
if title in body_lower:
findings.append(f"AUTHORITY IMPERSONATION: References executive title '{title.upper()}'")
techniques.append("authority_impersonation")
break
for pattern in [r"cannot take calls", r"in.{0,10}meetings", r"do not.{0,10}call", r"reply.{0,10}email only"]:
if re.search(pattern, body, re.IGNORECASE):
findings.append("COMMUNICATION ISOLATION: Sender discourages phone verification")
techniques.append("communication_isolation")
break
return findings, techniques
def calculate_threat_score(engine1_findings, engine2_findings, all_techniques):
weights = {
"header_mismatch": 2.0, "spf_fail": 2.5, "brand_impersonation": 3.0,
"suspicious_url": 2.5, "urgency_language": 1.5, "financial_request": 3.0,
"authority_impersonation": 2.0, "communication_isolation": 2.5,
}
raw_score = sum(weights.get(t, 1.0) for t in all_techniques)
score = min(int(raw_score), 10)
total = len(engine1_findings) + len(engine2_findings)
confidence = "HIGH" if total >= 5 else "MEDIUM" if total >= 3 else "LOW"
if score >= 8 and confidence == "HIGH":
verdict = "CONFIRMED PHISHING"
elif score >= 7:
verdict = "LIKELY PHISHING"
elif score >= 4:
verdict = "SUSPICIOUS"
else:
verdict = "LIKELY SAFE"
return score, confidence, verdict
def run_engine1(email_content):
headers = parse_headers(email_content)
h_findings, h_tech = analyze_headers(headers)
u_findings, u_tech, _ = extract_urls(email_content)
l_findings, l_tech, _ = detect_urgency_language(email_content)
return {"findings": h_findings + u_findings + l_findings, "techniques": h_tech + u_tech + l_tech, "headers": headers}
def run_engine2(email_content, headers):
b_findings, b_tech = detect_brand_impersonation(email_content, headers)
f_findings, f_tech = detect_financial_bec(email_content)
return {"findings": b_findings + f_findings, "techniques": b_tech + f_tech}
def check_ai_available():
"""Check if any supported AI API key is configured."""
if os.environ.get("OPENAI_API_KEY"):
return True
return False
def run_ai_enhancement(email_content, engine1, engine2, score, verdict):
"""Run optional AI enhancement for threat narrative analysis."""
try:
from openai import OpenAI
except ImportError:
return None
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return None
base_url = os.environ.get("OPENAI_BASE_URL")
model = os.environ.get("PHISHGUARD_MODEL", "gpt-4o-mini")
client_kwargs = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
findings_text = "\n".join(f"- {f}" for f in engine1["findings"] + engine2["findings"])
if not findings_text:
findings_text = "- No suspicious indicators detected by local engines"
prompt = f"""You are a senior threat analyst. The local detection engines have already identified the following indicators in this email:
LOCAL DETECTION RESULTS:
Threat Score: {score}/10
Verdict: {verdict}
Findings:
{findings_text}
EMAIL CONTENT:
{email_content}
Provide ONLY what pattern-matching cannot: contextual reasoning about this email.
Respond in this exact format:
THREAT NARRATIVE:
[2-3 sentences explaining the attacker's strategy and why this specific approach might fool the target]
SOPHISTICATION: [Amateur / Intermediate / Advanced]
VICTIM VULNERABILITY:
[1-2 sentences on what makes the target likely to fall for this, or why they would not]
SUGGESTED DETECTION RULE:
[One specific regex pattern or heuristic that would catch THIS attack variant in the future, written as a description a security engineer could implement]"""
try:
client = OpenAI(**client_kwargs)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=500,
)
return response.choices[0].message.content
except Exception as e:
return f"[AI Enhancement Error: {e}]"
Your AI enhancement layer is ready. It detects any available provider, sends structured findings for contextual analysis, and degrades gracefully when unavailable. Next up, you will tie everything together into a polished report that displays results from both engines, the AI narrative, MITRE ATT&CK mappings, and extracted IOCs.
Combine Everything and Generate the Report
Your two detection engines and the optional AI layer are all built. Now you need to connect them into a single pipeline that reads an email, runs every analysis function, and outputs a professional report.
In this step, you will extract actionable threat data (IOCs), build a formatted report that displays all findings in one place, and wire up the command-line interface so you can analyze any email file with a single command.
In this step, get ready to:
- Build an IOC extraction function that pulls URLs, IPs, and email addresses from analyzed emails.
- Build a formatted report display that shows Engine 1, Engine 2, AI Enhancement, MITRE ATT&CK mapping, and extracted IOCs.
- Add command-line argument handling and run PhishGuard against all three sample emails.
Build the IOC extraction function
Security teams need a quick list of suspicious artifacts they can feed into their SIEM or blocklist. These artifacts are called Indicators of Compromise (IOCs).
- In phishguard.py, find the detect_financial_bec function.
- Add the following function directly below it, above calculate_threat_score:
def extract_iocs(email_content):
# Collect all unique URLs, IP addresses, and emails from the raw content
iocs = {"urls": [], "ip_addresses": [], "email_addresses": []}
iocs["urls"] = list(set(re.findall(r'https?://[^\s<>"]+', email_content)))
iocs["ip_addresses"] = list(set(re.findall(r'\b(?:\d{1,3}\.){3}\d{1,3}\b', email_content)))
iocs["email_addresses"] = list(set(re.findall(r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}', email_content)))
return iocs
What does this code do?
This function uses three regex patterns to pull every URL, IP address, and email address out of the raw email text. Each result list is deduplicated with set() so you get unique artifacts only.
The output dictionary is ready to feed directly into a SIEM blocklist or a threat intelligence platform.
- Save phishguard.py.
- You will not see a change yet. The next chunk uses this function when building the full report.
Build the report display function
A professional analysis tool needs a clean, structured output that a security analyst can scan quickly. This function prints a formatted terminal report with clearly labeled sections for each detection engine.
- In phishguard.py, scroll to the bottom of the file and find the run_ai_enhancement function.
- Add the following function below it:
def display_report(filepath, engine1, engine2, score, confidence, verdict, iocs, techniques, ai_result):
# Report header with file info and threat score summary
print("\n" + "=" * 70)
print(" PHISHGUARD - Dual-Engine Phishing Analysis Report")
print("=" * 70)
print(f"\n File: {filepath}")
print(f" Subject: {engine1['headers']['subject']}")
print(f"\n THREAT SCORE: {score}/10 | CONFIDENCE: {confidence} | VERDICT: {verdict}")
# Engine 1 results: static indicators
print("\n" + "-" * 70)
print(" ENGINE 1: Static Analysis")
print("-" * 70)
if engine1["findings"]:
for f in engine1["findings"]:
print(f" [!] {f}")
else:
print(" [OK] No suspicious indicators detected")
# Engine 2 results: behavioral patterns
print("\n" + "-" * 70)
print(" ENGINE 2: Behavioral Correlation")
print("-" * 70)
if engine2["findings"]:
for f in engine2["findings"]:
print(f" [!] {f}")
else:
print(" [OK] No behavioral anomalies detected")
# AI enhancement section (shows results or instructions to enable)
print("\n" + "-" * 70)
print(" AI ENHANCEMENT")
print("-" * 70)
if ai_result:
print()
print(ai_result)
else:
print(" [--] Not active. Set OPENAI_API_KEY to enable AI threat narrative.")
print(" [--] Supports: OpenAI, Anthropic, Groq, Ollama (any OpenAI-compatible API)")
# MITRE ATT&CK technique mapping
print("\n" + "-" * 70)
print(" MITRE ATT&CK MAPPING")
print("-" * 70)
mapped = set()
for t in techniques:
if t in MITRE_MAPPING and t not in mapped:
print(f" - {MITRE_MAPPING[t]}")
mapped.add(t)
if not mapped:
print(" No techniques mapped")
# Extracted IOCs for SIEM ingestion
print("\n" + "-" * 70)
print(" EXTRACTED IOCs")
print("-" * 70)
if iocs["urls"]:
print("\n URLs:")
for url in iocs["urls"]:
print(f" - {url}")
if iocs["ip_addresses"]:
print("\n IP Addresses:")
for ip in iocs["ip_addresses"]:
print(f" - {ip}")
if iocs["email_addresses"]:
print("\n Email Addresses:")
for email in iocs["email_addresses"]:
print(f" - {email}")
# Report footer
print("\n" + "=" * 70)
print(" Analysis complete. Stay vigilant!")
print("=" * 70 + "\n")
What does this code do?
This function prints a structured terminal report with six clearly separated sections. Each section uses divider lines and labels so an analyst can scan the output quickly.
- The header shows the filename, subject line, threat score, confidence level, and verdict.
- Engine 1 and Engine 2 sections list their findings with [!] markers, or print [OK] if nothing was found.
- The AI Enhancement section shows the AI narrative when active, or tells the user how to enable it.
- The MITRE ATT&CK section maps detected techniques to their official IDs using the MITRE_MAPPING dictionary at the top of your file.
- The IOCs section lists all extracted URLs, IPs, and email addresses.
- Save phishguard.py.
- You will not see a change yet. The next chunk adds the main() function that calls everything together.
Add command-line arguments and run the tool
The final piece is the entry point. This function reads the email filename from the command line, orchestrates both engines, optionally runs AI enhancement, and hands everything to the report display.
- At the very bottom of phishguard.py, below the display_report function you just added, paste the following code:
def main():
# Check that user provided a file path argument
if len(sys.argv) < 2:
print("Usage: python phishguard.py <email_file>")
print("Example: python phishguard.py sample_emails/phishing_01.txt")
sys.exit(1)
filepath = sys.argv[1]
ai_available = check_ai_available()
# Print status banner
print(f"\n[*] PhishGuard Dual-Engine Analysis")
print(f"[*] Target: {filepath}")
print(f"[*] AI Enhancement: {'ACTIVE' if ai_available else 'Not configured (set OPENAI_API_KEY to enable)'}")
print(f"[*] Running Engine 1 (Static Analysis)...")
# Run Engine 1: headers, URLs, urgency language
email_content = read_email_file(filepath)
engine1 = run_engine1(email_content)
print(f"[*] Engine 1 complete. Indicators: {len(engine1['findings'])}")
# Run Engine 2: brand impersonation, BEC patterns
print(f"[*] Running Engine 2 (Behavioral Correlation)...")
engine2 = run_engine2(email_content, engine1["headers"])
print(f"[*] Engine 2 complete. Behaviors: {len(engine2['findings'])}")
# Calculate unified threat score from all techniques
all_techniques = engine1["techniques"] + engine2["techniques"]
score, confidence, verdict = calculate_threat_score(engine1["findings"], engine2["findings"], all_techniques)
# Run AI enhancement if an API key is available
ai_result = None
if ai_available:
print(f"[*] Running AI Enhancement...")
ai_result = run_ai_enhancement(email_content, engine1, engine2, score, verdict)
print(f"[*] AI Enhancement complete.")
# Extract IOCs and display the full report
iocs = extract_iocs(email_content)
display_report(filepath, engine1, engine2, score, confidence, verdict, iocs, all_techniques, ai_result)
if __name__ == "__main__":
main()
What does this code do?
The main() function is the orchestrator. It checks for a command-line argument, runs Engine 1, runs Engine 2, calculates the threat score, optionally triggers AI analysis, extracts IOCs, then displays the full report.
The if __name__ == "__main__": guard ensures main() only runs when you execute the file directly, not when importing functions from it.
- Save phishguard.py.
- Run PhishGuard against the first phishing sample by running this command in your terminal:
python phishguard.py sample_emails/phishing_01.txt
- You should see a formatted report with the verdict CONFIRMED PHISHING, multiple [!] findings in both Engine 1 and Engine 2, MITRE ATT&CK technique mappings, and extracted IOCs.
Seeing a syntax error or no output?
- Make sure you saved the file after adding all three functions.
- Check that if __name__ == "__main__": is at the very bottom of the file with no indentation.
- Confirm you are running the command from inside the phishguard folder (where phishguard.py lives).
Why is PhishGuard not producing any output?
- Now run it against the legitimate email by running:
python phishguard.py sample_emails/legitimate_01.txt
- You should see the verdict LIKELY SAFE with [OK] messages in both engine sections and no MITRE mappings.
- Finally, run it against the BEC email by running:
python phishguard.py sample_emails/phishing_02.txt
- You should see a verdict of SUSPICIOUS or LIKELY PHISHING. Engine 1 shows urgency language findings but no header issues (because the BEC email has valid SPF and DKIM). Engine 2 catches the financial request, authority impersonation, and communication isolation patterns.
Why does phishing_02.txt pass header checks?
This is exactly why you need Engine 2. Business Email Compromise attacks use legitimately configured domains. The headers look fine, SPF passes, and DKIM is present. Only behavioral analysis catches the social engineering patterns: an executive title combined with financial urgency and a request to avoid phone calls.
✔️ Awesome, I've got everything!
Great. Double check you have saved phishguard.py and all three sample analyses produced the expected verdicts.
ⓧ I'd like to double check the full code
Here is the complete phishguard.py file. Compare it against yours line by line.
import sys
import os
import re
KNOWN_BRANDS = {
"amazon": ["amazon.com", "amazon.co.uk", "amazon.de"],
"microsoft": ["microsoft.com", "outlook.com", "live.com"],
"google": ["google.com", "gmail.com"],
"paypal": ["paypal.com"],
"apple": ["apple.com", "icloud.com"],
"netflix": ["netflix.com"],
"github": ["github.com"],
}
MITRE_MAPPING = {
"header_mismatch": "T1566.001: Phishing: Spearphishing Attachment/Link",
"spf_fail": "T1566.001: Phishing: Spearphishing Link",
"brand_impersonation": "T1566.001: Phishing: Spearphishing Link",
"urgency_language": "T1204.001: User Execution: Malicious Link",
"suspicious_url": "T1566.002: Phishing: Spearphishing Link",
"financial_request": "T1566.003: Phishing: Spearphishing via Service (BEC)",
"authority_impersonation": "T1534: Internal Spearphishing",
"communication_isolation": "T1534: Internal Spearphishing",
}
def read_email_file(filepath):
if not os.path.exists(filepath):
print(f"Error: File '{filepath}' not found.")
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
print(f"Error: File '{filepath}' is empty.")
sys.exit(1)
return content
def parse_headers(email_content):
headers = {"from": "", "return_path": "", "spf": "", "dkim": "", "subject": ""}
header_section = email_content.split("\n\n")[0]
for line in header_section.split("\n"):
line_lower = line.lower().strip()
if line_lower.startswith("from:"):
headers["from"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("return-path:"):
headers["return_path"] = line.split(":", 1)[1].strip().strip("<>")
elif line_lower.startswith("x-spf:"):
headers["spf"] = line.split(":", 1)[1].strip()
elif line_lower.startswith("dkim-signature:"):
value = line.split(":", 1)[1].strip()
headers["dkim"] = "none" if value.lower() == "none" else "present"
elif line_lower.startswith("subject:"):
headers["subject"] = line.split(":", 1)[1].strip()
return headers
def analyze_headers(headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
return_path_domain = headers["return_path"].split("@")[-1] if "@" in headers["return_path"] else ""
if from_domain and return_path_domain and from_domain != return_path_domain:
findings.append(f"HEADER MISMATCH: From domain ({from_domain}) != Return-Path domain ({return_path_domain})")
techniques.append("header_mismatch")
if "fail" in headers["spf"].lower():
findings.append(f"SPF FAILURE: {headers['spf']}")
techniques.append("spf_fail")
if headers["dkim"] == "none":
findings.append("DKIM MISSING: No DKIM signature present")
return findings, techniques
def extract_urls(email_content):
url_pattern = re.compile(r'https?://[^\s<>"]+')
urls = url_pattern.findall(email_content)
findings = []
techniques = []
for url in urls:
issues = []
url_lower = url.lower()
if re.match(r'https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', url):
issues.append("contains IP address instead of domain")
for fake, real in {"0": "o", "1": "l", "vv": "w"}.items():
if fake in url_lower:
issues.append(f"possible homoglyph: '{fake}' may impersonate '{real}'")
for tld in [".xyz", ".top", ".buzz", ".click", ".loan"]:
if tld in url_lower:
issues.append(f"suspicious TLD: {tld}")
if issues:
findings.append(f"SUSPICIOUS URL: {url} ({', '.join(issues)})")
techniques.append("suspicious_url")
return findings, techniques, urls
def detect_urgency_language(email_content):
urgency_patterns = [
r"immediately", r"urgent", r"within \d+ hours",
r"account.{0,20}suspend", r"account.{0,20}limited",
r"verify.{0,20}identity", r"permanently.{0,20}(closed|suspended|deleted)",
r"act now", r"cannot take calls", r"before.{0,20}deadline", r"process.{0,20}today",
]
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
matched = []
for pattern in urgency_patterns:
matches = re.findall(pattern, body, re.IGNORECASE)
if matches:
matched.extend(matches)
findings = []
techniques = []
if matched:
findings.append(f"URGENCY LANGUAGE: {len(matched)} pressure indicator(s): {', '.join(matched[:5])}")
techniques.append("urgency_language")
return findings, techniques, len(matched)
def detect_brand_impersonation(email_content, headers):
findings = []
techniques = []
from_domain = headers["from"].split("@")[-1] if "@" in headers["from"] else ""
full_text = email_content.lower()
for brand, legit_domains in KNOWN_BRANDS.items():
if brand in full_text and from_domain not in legit_domains and from_domain:
findings.append(f"BRAND IMPERSONATION: Mentions '{brand}' but sent from '{from_domain}' (legit: {', '.join(legit_domains)})")
techniques.append("brand_impersonation")
break
return findings, techniques
def detect_financial_bec(email_content):
findings = []
techniques = []
body = email_content.split("\n\n", 1)[1] if "\n\n" in email_content else email_content
body_lower = body.lower()
financial_patterns = [r"wire transfer", r"bank.{0,10}account", r"routing.{0,5}\d{9}", r"\$[\d,]+", r"process.{0,20}payment"]
matches = [p for p in financial_patterns if re.search(p, body, re.IGNORECASE)]
if len(matches) >= 2:
findings.append(f"FINANCIAL REQUEST: {len(matches)} financial indicators detected")
techniques.append("financial_request")
for title in ["ceo", "cfo", "cto", "president", "director", "vp"]:
if title in body_lower:
findings.append(f"AUTHORITY IMPERSONATION: References executive title '{title.upper()}'")
techniques.append("authority_impersonation")
break
for pattern in [r"cannot take calls", r"in.{0,10}meetings", r"do not.{0,10}call", r"reply.{0,10}email only"]:
if re.search(pattern, body, re.IGNORECASE):
findings.append("COMMUNICATION ISOLATION: Sender discourages phone verification")
techniques.append("communication_isolation")
break
return findings, techniques
def extract_iocs(email_content):
iocs = {"urls": [], "ip_addresses": [], "email_addresses": []}
iocs["urls"] = list(set(re.findall(r'https?://[^\s<>"]+', email_content)))
iocs["ip_addresses"] = list(set(re.findall(r'\b(?:\d{1,3}\.){3}\d{1,3}\b', email_content)))
iocs["email_addresses"] = list(set(re.findall(r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}', email_content)))
return iocs
def calculate_threat_score(engine1_findings, engine2_findings, all_techniques):
weights = {
"header_mismatch": 2.0, "spf_fail": 2.5, "brand_impersonation": 3.0,
"suspicious_url": 2.5, "urgency_language": 1.5, "financial_request": 3.0,
"authority_impersonation": 2.0, "communication_isolation": 2.5,
}
raw_score = sum(weights.get(t, 1.0) for t in all_techniques)
score = min(int(raw_score), 10)
total = len(engine1_findings) + len(engine2_findings)
confidence = "HIGH" if total >= 5 else "MEDIUM" if total >= 3 else "LOW"
if score >= 8 and confidence == "HIGH":
verdict = "CONFIRMED PHISHING"
elif score >= 7:
verdict = "LIKELY PHISHING"
elif score >= 4:
verdict = "SUSPICIOUS"
else:
verdict = "LIKELY SAFE"
return score, confidence, verdict
def run_engine1(email_content):
headers = parse_headers(email_content)
h_findings, h_tech = analyze_headers(headers)
u_findings, u_tech, _ = extract_urls(email_content)
l_findings, l_tech, _ = detect_urgency_language(email_content)
return {"findings": h_findings + u_findings + l_findings, "techniques": h_tech + u_tech + l_tech, "headers": headers}
def run_engine2(email_content, headers):
b_findings, b_tech = detect_brand_impersonation(email_content, headers)
f_findings, f_tech = detect_financial_bec(email_content)
return {"findings": b_findings + f_findings, "techniques": b_tech + f_tech}
def check_ai_available():
"""Check if any supported AI API key is configured."""
if os.environ.get("OPENAI_API_KEY"):
return True
return False
def run_ai_enhancement(email_content, engine1, engine2, score, verdict):
"""Run optional AI enhancement for threat narrative analysis."""
try:
from openai import OpenAI
except ImportError:
return None
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return None
base_url = os.environ.get("OPENAI_BASE_URL")
model = os.environ.get("PHISHGUARD_MODEL", "gpt-4o-mini")
client_kwargs = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
findings_text = "\n".join(f"- {f}" for f in engine1["findings"] + engine2["findings"])
if not findings_text:
findings_text = "- No suspicious indicators detected by local engines"
prompt = f"""You are a senior threat analyst. The local detection engines have already identified the following indicators in this email:
LOCAL DETECTION RESULTS:
Threat Score: {score}/10
Verdict: {verdict}
Findings:
{findings_text}
EMAIL CONTENT:
{email_content}
Provide ONLY what pattern-matching cannot: contextual reasoning about this email.
Respond in this exact format:
THREAT NARRATIVE:
[2-3 sentences explaining the attacker's strategy and why this specific approach might fool the target]
SOPHISTICATION: [Amateur / Intermediate / Advanced]
VICTIM VULNERABILITY:
[1-2 sentences on what makes the target likely to fall for this, or why they would not]
SUGGESTED DETECTION RULE:
[One specific regex pattern or heuristic that would catch THIS attack variant in the future, written as a description a security engineer could implement]"""
try:
client = OpenAI(**client_kwargs)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=500,
)
return response.choices[0].message.content
except Exception as e:
return f"[AI Enhancement Error: {e}]"
def display_report(filepath, engine1, engine2, score, confidence, verdict, iocs, techniques, ai_result):
print("\n" + "=" * 70)
print(" PHISHGUARD - Dual-Engine Phishing Analysis Report")
print("=" * 70)
print(f"\n File: {filepath}")
print(f" Subject: {engine1['headers']['subject']}")
print(f"\n THREAT SCORE: {score}/10 | CONFIDENCE: {confidence} | VERDICT: {verdict}")
print("\n" + "-" * 70)
print(" ENGINE 1: Static Analysis")
print("-" * 70)
if engine1["findings"]:
for f in engine1["findings"]:
print(f" [!] {f}")
else:
print(" [OK] No suspicious indicators detected")
print("\n" + "-" * 70)
print(" ENGINE 2: Behavioral Correlation")
print("-" * 70)
if engine2["findings"]:
for f in engine2["findings"]:
print(f" [!] {f}")
else:
print(" [OK] No behavioral anomalies detected")
print("\n" + "-" * 70)
print(" AI ENHANCEMENT")
print("-" * 70)
if ai_result:
print()
print(ai_result)
else:
print(" [--] Not active. Set OPENAI_API_KEY to enable AI threat narrative.")
print(" [--] Supports: OpenAI, Anthropic, Groq, Ollama (any OpenAI-compatible API)")
print("\n" + "-" * 70)
print(" MITRE ATT&CK MAPPING")
print("-" * 70)
mapped = set()
for t in techniques:
if t in MITRE_MAPPING and t not in mapped:
print(f" - {MITRE_MAPPING[t]}")
mapped.add(t)
if not mapped:
print(" No techniques mapped")
print("\n" + "-" * 70)
print(" EXTRACTED IOCs")
print("-" * 70)
if iocs["urls"]:
print("\n URLs:")
for url in iocs["urls"]:
print(f" - {url}")
if iocs["ip_addresses"]:
print("\n IP Addresses:")
for ip in iocs["ip_addresses"]:
print(f" - {ip}")
if iocs["email_addresses"]:
print("\n Email Addresses:")
for email in iocs["email_addresses"]:
print(f" - {email}")
print("\n" + "=" * 70)
print(" Analysis complete. Stay vigilant!")
print("=" * 70 + "\n")
def main():
if len(sys.argv) < 2:
print("Usage: python phishguard.py <email_file>")
print("Example: python phishguard.py sample_emails/phishing_01.txt")
sys.exit(1)
filepath = sys.argv[1]
ai_available = check_ai_available()
print(f"\n[*] PhishGuard Dual-Engine Analysis")
print(f"[*] Target: {filepath}")
print(f"[*] AI Enhancement: {'ACTIVE' if ai_available else 'Not configured (set OPENAI_API_KEY to enable)'}")
print(f"[*] Running Engine 1 (Static Analysis)...")
email_content = read_email_file(filepath)
engine1 = run_engine1(email_content)
print(f"[*] Engine 1 complete. Indicators: {len(engine1['findings'])}")
print(f"[*] Running Engine 2 (Behavioral Correlation)...")
engine2 = run_engine2(email_content, engine1["headers"])
print(f"[*] Engine 2 complete. Behaviors: {len(engine2['findings'])}")
all_techniques = engine1["techniques"] + engine2["techniques"]
score, confidence, verdict = calculate_threat_score(engine1["findings"], engine2["findings"], all_techniques)
ai_result = None
if ai_available:
print(f"[*] Running AI Enhancement...")
ai_result = run_ai_enhancement(email_content, engine1, engine2, score, verdict)
print(f"[*] AI Enhancement complete.")
iocs = extract_iocs(email_content)
display_report(filepath, engine1, engine2, score, confidence, verdict, iocs, all_techniques, ai_result)
if __name__ == "__main__":
main()
Secret mission
Batch Analysis with Markdown Threat Report
Your tool analyzes one email at a time. Real SOC analysts process hundreds. Add batch mode that analyzes an entire folder and generates a professional Markdown threat intelligence report complete with an ASCII risk heatmap, aggregated IOCs, and actionable recommendations.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future use or remove them from your machine.
Resources you used:
- The phishguard/ project folder on your Desktop (contains phishguard.py, sample_emails/, and threat_report.md).
- The openai Python package (if you installed it for AI enhancement).
Keep everything running
No action needed. Choose this if you want to keep using PhishGuard to analyze suspicious emails.
- Your phishguard/ folder stays on your Desktop, ready to analyze new emails anytime.
- The openai package (if installed) remains available for AI enhancement in future sessions.
- Any environment variables you set (OPENAI_API_KEY, etc.) only last for the current terminal session and will disappear when you close the terminal.
Pause - I'll come back to this later
Since this project is entirely local, there is nothing to pause. Your files sit on disk without consuming any resources while idle.
- Close VS Code to free up memory.
- Close your terminal window. Any environment variables you exported will be cleared automatically.
- When you come back, open the phishguard/ folder in VS Code and open a new terminal to pick up where you left off.
Delete - I don't want to use this again
Remove all project files and the optional package to start completely fresh.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/phishguard
Windows
rmdir /s /q %USERPROFILE%\Desktop\phishguard
- If you installed the openai package, uninstall it by running:
pip uninstall openai
- Confirm the deletion by typing y when prompted.
- Any environment variables you set (OPENAI_API_KEY, OPENAI_BASE_URL, PHISHGUARD_MODEL) are session-only and will disappear when you close your terminal.
Nice Work!
Nice Work!
Nice work! You just built PhishGuard, a professional dual-engine phishing analyzer with an optional AI enhancement layer that activates automatically when any compatible API key is available.
You've learned how to:
- Built PhishGuard with dual detection engines (static analysis for header/URL/language checks and behavioral correlation for brand impersonation, BEC patterns, and authority impersonation) that work perfectly standalone with zero external dependencies.
- Added a provider-agnostic AI layer using the openai package that auto-activates with any OpenAI-compatible API key and provides threat narratives, sophistication assessments, and suggested detection rules that pattern-matching cannot produce.
- Extracted IOCs (URLs, IPs, email addresses) ready for SIEM ingestion and mapped findings to real MITRE ATT&CK techniques including T1566 (Phishing) and T1534 (Internal Spearphishing).
- Secret Mission: Generated a professional Markdown threat intelligence report with batch analysis, ASCII risk heatmap, aggregated IOCs, and executive recommendations.
Ready to quiz yourself?