Build a Phishing Domain Detector
Build a CLI tool that scores any domain's phishing risk with explainable signals.
Introduction
30 Second Summary
You get an email from "paypa1.com" asking you to verify your account. It looks legit at a glance, but something is off. Real security teams catch these deceptive domains before they ever reach your inbox.
In this project, you will build a command-line Python tool that analyzes any domain and tells you exactly why it looks suspicious. It scores each domain from 0 to 100 with a full breakdown of the detection signals that fired.
What You'll Build
You paste a domain like g00gle-login.xyz into your terminal and within a second the tool breaks it down: homoglyph attack detected, suspicious TLD, keyword stuffing, risk score 85/100 HIGH.
By the end of this project, you'll have:
- A CLI phishing detector that takes any domain and returns a risk score from 0-100 with plain-English explanations of every signal that triggered.
- Typosquatting detection that flags domains 1-2 characters away from known brands. Paste in "paypall.com" and watch it get identified as one edit away from "paypal".
- Homoglyph detection that catches character substitution tricks. Run "paypa1.com" and see the tool decode the "1" as an "l" swap targeting PayPal.
- Secret Mission: Add batch scanning to process a whole file of suspicious domains and export a CSV report, just like a real SOC analyst would.
Are there any prerequisites?
You should be comfortable with basic Python concepts like variables, functions, if-statements, and lists. No cybersecurity experience is needed.
Before We Start
Phishing attacks start with a domain name. Before a victim ever sees a fake login page or downloads malware, they first encounter a URL designed to look trustworthy.
In this step, you will set up Python and the libraries you need to start building your phishing domain detector.
In this step, get ready to:
- Install Python 3.13 and verify it works.
- Create your project folder with a virtual environment.
- Install the domain analysis libraries and open VS Code.
Install Python 3.13
Your detector needs Python 3.13 or higher. The libraries you will install later require at least Python 3.10, and 3.13 is the current stable release.
- Open your terminal by pressing Cmd+Space (macOS) to open Spotlight, then typing Terminal and pressing Enter.
- Check if Python 3.13 is already installed by running:
python3 --version
✔️ I see Python 3.13
You are all set. Skip ahead to the next substep.
ⓧ I see an older version
macOS often ships with an older Python. You need to install 3.13 alongside it using Homebrew.
- Install Python 3.13 by running:
brew install python@3.13
- After the install finishes, verify the version by running:
python3 --version
You should see Python 3.13.x in the output. If python3 still shows the old version, try running python3.13 --version instead. If that works, use python3.13 in place of python3 for the rest of this project.
ⓧ Command not found
Python is not installed yet. Install it with Homebrew.
- Install Python 3.13 by running:
brew install python@3.13
- After the install finishes, verify by running:
python3 --version
You should see Python 3.13.x in the output.
Homebrew not found?
If you see a "command not found: brew" error, Homebrew is not installed. Visit brew.sh and follow the one-line install command, then restart your terminal and try again.
help me install Homebrew on my Mac
Create the project folder and virtual environment
You will build your detector inside a dedicated folder on your Desktop. A virtual environment keeps your project's packages isolated from the rest of your system.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir phish-detector
cd phish-detector
- Create and activate a virtual environment by running these commands:
python3 -m venv venv
source venv/bin/activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
Why a virtual environment?
Starting with Python 3.12, Homebrew follows PEP 668 and requires virtual environments for pip installs. Without one, pip install will refuse to install packages system-wide.
Don't see (venv) in your prompt?
Make sure you typed source venv/bin/activate (not just venv/bin/activate without source). Also confirm you are inside the phish-detector folder where you created the venv.
help me activate my Python virtual environment
Install dependencies and open VS Code
Your detector needs two libraries: tldextract for parsing domain names into their parts, and Levenshtein for measuring how similar two strings are.
- Install both packages by running:
pip install tldextract==5.3.1 Levenshtein==0.27.3
You will see download and install progress in your terminal. Once it finishes, verify the packages loaded correctly.
Before you run the next command, what do you think will happen if both packages installed correctly?
- Verify your packages work by running:
python3 -c "import tldextract; from Levenshtein import distance; print('Ready')"
You should see Ready printed in your terminal. This confirms both libraries are installed and importable.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active (you should see (venv) in your prompt). If it is not, run source venv/bin/activate again, then re-run the pip install command.
help me fix a ModuleNotFoundError for tldextract or Levenshtein
Now open your project in VS Code so you have an editor ready for the next step.
- Open the project folder in VS Code by running:
code .
✔️ VS Code opens with my project
You should see the VS Code window with phish-detector in the Explorer sidebar. You are ready to start building.
ⓧ Command not found or VS Code not installed
If the code command is not recognized, you need to install VS Code first.
- Download VS Code from code.visualstudio.com and install it.
- Open VS Code.
- Press Cmd+Shift+P to open the Command Palette.
- Type Shell Command: Install 'code' command in PATH and select it.
- Restart your terminal, then run code . again from inside the phish-detector folder.
Your environment is ready. Next up, you will build the domain parser that breaks suspicious URLs into their component parts and starts scoring them.
Build the Domain Parser and Structural Analyzer
Your environment is ready. Now it's time to build the core of your phishing detector.
Before you can flag a domain as suspicious, you need to break it apart and measure its structure. Phishing domains often reveal themselves through basic structural tells: excessive length, deep subdomain chains, hyphen abuse, and randomised character sequences. In this step, you will build the first detection layer that spots these red flags.
In this step, get ready to:
- Create the domain parser that breaks URLs into subdomain, root, and TLD.
- Build a Shannon entropy calculator to detect randomly generated domains.
- Wire up structural analysis checks and a scoring engine with formatted output.
Create the file with imports and entropy calculation
Every domain analysis starts with measuring how "random" a string looks. Legitimate domains like google have low entropy (few unique characters, repeated patterns). Randomly generated phishing domains like xn7kqbc9df have high entropy. Shannon entropy gives you a single number that captures this randomness.
- In VS Code, create a new file called phish_detector.py in your phish-detector folder.
- Add the imports and the entropy calculation function by pasting this code:
import sys
import math
import csv
from collections import Counter
import tldextract
from Levenshtein import distance as levenshtein_distance
def calculate_entropy(text):
"""Calculate Shannon entropy of a string."""
if not text:
return 0.0
counter = Counter(text)
length = len(text)
entropy = -sum(
(count / length) * math.log2(count / length)
for count in counter.values()
)
return round(entropy, 3)
What does this code do?
The imports bring in your two installed libraries: tldextract for parsing domains and Levenshtein for edit-distance calculations (used in a later step).
The calculate_entropy() function uses the Shannon entropy formula. It counts how often each character appears, then calculates a score from 0 (all same character) upward. Scores above 4.0 suggest a randomly generated string.
- Save phish_detector.py.
- Verify the imports and function work by running this in your terminal:
python3 -c "from phish_detector import calculate_entropy; print(calculate_entropy('google')); print(calculate_entropy('xn7kqbc9df'))"
You should see two numbers printed. The first (for 'google') will be around 2.25. The second (for 'xn7kqbc9df') will be around 3.3. Higher entropy means more randomness.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active. Your terminal prompt should start with (venv). If it does not, run source venv/bin/activate from inside your phish-detector folder.
help me fix a ModuleNotFoundError when importing tldextract in my phish_detector.py
Now add the structural analysis function that checks domain length, subdomain depth, hyphen count, and entropy level.
- In phish_detector.py, add the analyze_structure() function below calculate_entropy():
def analyze_structure(domain_parts):
"""Analyze structural signals of the domain."""
signals = []
score = 0
subdomain = domain_parts.subdomain
domain = domain_parts.domain
suffix = domain_parts.suffix
if len(domain) > 20:
signals.append(f"Long domain name ({len(domain)} chars)")
score += 15
if subdomain:
depth = subdomain.count(".") + 1
if depth >= 3:
signals.append(f"Deep subdomain nesting (depth: {depth})")
score += 20
elif depth >= 2:
signals.append(f"Multiple subdomains (depth: {depth})")
score += 10
hyphen_count = domain.count("-")
if hyphen_count >= 3:
signals.append(f"Excessive hyphens ({hyphen_count})")
score += 15
elif hyphen_count >= 1:
signals.append(f"Contains hyphens ({hyphen_count})")
score += 5
entropy = calculate_entropy(domain)
if entropy > 4.0:
signals.append(f"High entropy ({entropy}) - may be randomly generated")
score += 20
elif entropy > 3.5:
signals.append(f"Elevated entropy ({entropy})")
score += 10
return score, signals
What does this code do?
This function takes parsed domain parts (from tldextract) and runs four structural checks. Each check adds points to the risk score and appends a human-readable signal description.
- Domains longer than 20 characters get flagged. Phishing domains often jam many words together.
- Deep subdomains (like login.verify.secure.example.com) are suspicious because legitimate sites rarely nest that deep.
- Excessive hyphens signal keyword stuffing (like secure-login-verify-account.com).
- High entropy (from calculate_entropy()) flags randomly generated domain names.
- Save phish_detector.py.
- Test that the structural analyzer works by running:
python3 -c "import tldextract; from phish_detector import analyze_structure; parts = tldextract.extract('secure-login.some-long-suspicious-domain.xyz'); print(analyze_structure(parts))"
You should see a tuple like (20, ['Long domain name (30 chars)', 'Contains hyphens (1)']). The first value is the score and the second is the list of triggered signals.
Seeing a NameError or AttributeError?
Check that analyze_structure is placed below calculate_entropy in your file. Python reads top to bottom, so analyze_structure must come after the function it calls.
help me debug an error in my analyze_structure function
Add the scoring engine and formatted output
The structural analyzer produces raw scores and signals. Now you need a function that orchestrates the full analysis, classifies the risk level, and a report printer that formats it for human eyes.
- Below analyze_structure(), add the first part of analyze_domain() which parses the input and determines the risk level:
def analyze_domain(url):
"""Run all analyzers on a domain and return risk assessment."""
parts = tldextract.extract(url)
if not parts.domain:
return {"error": "Could not parse domain from input"}
total_score = 0
all_signals = []
struct_score, struct_signals = analyze_structure(parts)
total_score += struct_score
all_signals.extend(struct_signals)
total_score = min(total_score, 100)
if total_score >= 70:
risk_level = "HIGH"
elif total_score >= 40:
risk_level = "MEDIUM"
elif total_score >= 15:
risk_level = "LOW"
else:
risk_level = "SAFE"
What does this code do?
The tldextract.extract() call splits any URL into its subdomain, domain, and suffix (TLD) parts. It handles complex TLDs like .co.uk correctly.
The risk level thresholds (70+ HIGH, 40+ MEDIUM, 15+ LOW) are calibrated so that one strong signal or a combination of weaker ones triggers the right alert level.
- Still inside the analyze_domain() function (keep the indentation), add the domain string assembly and return statement directly below the risk level logic:
domain_str = (
f"{parts.subdomain + '.' if parts.subdomain else ''}"
f"{parts.domain}.{parts.suffix}"
)
return {
"domain": domain_str,
"parsed": {
"subdomain": parts.subdomain,
"domain": parts.domain,
"tld": parts.suffix,
},
"risk_score": total_score,
"risk_level": risk_level,
"signals": all_signals,
}
- Save phish_detector.py and verify the full analysis pipeline works by running:
python3 -c "from phish_detector import analyze_domain; result = analyze_domain('test-domain.xyz'); print(result['risk_score'], result['risk_level'], result['signals'])"
You should see a score, risk level, and a list of any signals that fired. The exact values depend on the structural characteristics of the test domain.
Now add the report printer that formats the results into a readable display.
- Below analyze_domain(), add the print_report() function:
def print_report(result):
"""Print a formatted analysis report."""
if "error" in result:
print(f"\nError: {result['error']}")
return
print("\n" + "=" * 55)
print(" PHISHING DOMAIN ANALYSIS REPORT")
print("=" * 55)
print(f"\n Domain: {result['domain']}")
print(f" Subdomain: {result['parsed']['subdomain'] or '(none)'}")
print(f" Root: {result['parsed']['domain']}")
print(f" TLD: .{result['parsed']['tld']}")
print(f"\n Risk Score: {result['risk_score']}/100")
print(f" Risk Level: {result['risk_level']}")
if result["signals"]:
print(f"\n Signals Detected ({len(result['signals'])}):")
print(" " + "-" * 40)
for signal in result["signals"]:
print(f" - {signal}")
else:
print("\n No suspicious signals detected.")
print("\n" + "=" * 55)
What does this code do?
This function formats the analysis dictionary into a structured report. It shows the domain breakdown (subdomain, root, TLD), the numerical risk score, the risk level classification, and each signal that contributed to the score.
- Finally, add the main() function and entry point below print_report():
def main():
if len(sys.argv) < 2:
print("Usage: python phish_detector.py <domain>")
print(" python phish_detector.py test")
print("Example: python phish_detector.py g00gle-login.xyz")
sys.exit(1)
target = sys.argv[1]
if target == "test":
pass
else:
result = analyze_domain(target)
print_report(result)
if __name__ == "__main__":
main()
What does this code do?
The main() function reads the domain from the command line arguments. If no argument is provided, it prints usage instructions and exits. The test argument is a placeholder for a test battery you will build in a later step.
The if __name__ == "__main__" guard ensures main() only runs when you execute the file directly, not when importing functions from it.
- Save phish_detector.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file, then move on to testing.
ⓧ I'd like to double check the full code
Scroll up and confirm your phish_detector.py contains each of these sections in order:
- Imports (sys, math, csv, Counter, tldextract, levenshtein_distance).
- The calculate_entropy() function.
- The analyze_structure() function with length, subdomain, hyphen, and entropy checks.
- The analyze_domain() function that calls analyze_structure() and returns a result dictionary.
- The print_report() function.
- The main() function and if __name__ == "__main__" block at the bottom.
Test with a suspicious-looking domain
Your tool is ready for its first real test. Before you run it, think about what signals a domain like secure-login.some-long-suspicious-domain.xyz might trigger.
- What do you expect the risk level to be? Consider the domain's length, hyphens, and subdomain depth. Make a prediction before running the command.
- Run your phishing detector against a suspicious domain:
python3 phish_detector.py secure-login.some-long-suspicious-domain.xyz
You should see a full analysis report with a parsed domain breakdown, multiple signals detected (long domain name, hyphens, subdomain depth), and a risk score that classifies it as LOW or higher.
- Now test a legitimate domain to confirm it reports safely:
python3 phish_detector.py google.com
This one should report SAFE with no suspicious signals detected. The domain is short, has no hyphens, no subdomains, and low entropy.
Seeing a TypeError or unexpected output?
Make sure the indentation inside analyze_domain() is consistent. The domain_str and return lines must be indented at the same level as the if total_score >= 70: line above them (inside the function, not inside the if-block).
help me debug my phish_detector.py output
Your structural analyzer is working. It can spot domains that are too long, too deep, too hyphenated, or too random. But try running python3 phish_detector.py paypall.com and notice what happens. That domain is short, simple, and has a normal TLD. Your structural checks say it is safe. Next up, you will add typosquatting detection to catch exactly this kind of deception.
Add Typosquatting Detection
Your structural analyzer catches long domains, deep subdomains, excessive hyphens, and high-entropy strings. But structure alone misses the most dangerous phishing technique: typosquatting.
Typosquatting is when attackers register domains that are one or two characters off from a real brand. A domain like paypall.com has completely normal structure. It's short, uses a common TLD, and has no hyphens. Your current tool would call it safe. In this step, you'll add a second detection layer that catches these near-miss domains.
In this step, get ready to:
- Define a list of known brand targets to compare against.
- Write a typosquatting checker using Levenshtein distance.
- Wire the new checker into your scoring engine and test it.
Run your detector against a known phishing domain
Before adding any new code, let's see how your current tool handles a classic typosquatting domain.
Before you run this, what do you think the risk level will be for paypall.com? It's only one letter off from PayPal.
- Test this domain by running:
python3 phish_detector.py paypall.com
You'll see a risk score of 0 and risk level SAFE. No signals detected.
That's the gap. A domain that is one character away from "paypal" passes your structural checks with flying colors because its structure is perfectly normal. This is exactly what you're about to fix.
Add a brand list and the typosquatting checker
To detect typosquatting, you need two things: a list of brands that attackers commonly impersonate, and a function that measures how close any domain is to those brands using Levenshtein distance.
- In phish_detector.py, find the blank line after your imports (after from Levenshtein import distance as levenshtein_distance). Add this brand list constant:
KNOWN_BRANDS = [
"google", "facebook", "amazon", "apple", "microsoft",
"netflix", "paypal", "instagram", "twitter", "linkedin",
"bankofamerica", "chase", "wellsfargo", "dropbox", "adobe",
]
- Now scroll down past the analyze_structure() function. Add this new function between analyze_structure() and analyze_domain():
def check_typosquatting(domain):
"""Check if domain is suspiciously close to a known brand."""
signals = []
score = 0
for brand in KNOWN_BRANDS:
dist = levenshtein_distance(domain.lower(), brand)
if dist == 0:
continue
if dist == 1:
signals.append(f"1 edit away from '{brand}' (likely typosquatting)")
score += 35
elif dist == 2:
signals.append(f"2 edits away from '{brand}' (possible typosquatting)")
score += 20
return score, signals
What does this code do?
- KNOWN_BRANDS lists the 15 most commonly impersonated brands in phishing attacks. These are the targets attackers try to mimic.
- check_typosquatting() compares the input domain against every brand using levenshtein_distance(). This counts the minimum number of single-character edits (insertions, deletions, or substitutions) needed to turn one string into another.
- A distance of 1 means the domain is a single typo away from a brand (high risk, +35 points). A distance of 2 is a weaker signal (+20 points). An exact match (distance 0) is skipped because that means the domain IS the real brand.
- Save phish_detector.py.
Wire it into the scoring engine and test
The function exists, but your analyzer does not call it yet. You need to add it to the pipeline inside analyze_domain().
- In analyze_domain(), find these lines:
struct_score, struct_signals = analyze_structure(parts)
total_score += struct_score
all_signals.extend(struct_signals)
total_score = min(total_score, 100)
- Add the typosquatting check between the structure check and the min() cap, so the section looks like this:
struct_score, struct_signals = analyze_structure(parts)
total_score += struct_score
all_signals.extend(struct_signals)
typo_score, typo_signals = check_typosquatting(parts.domain)
total_score += typo_score
all_signals.extend(typo_signals)
total_score = min(total_score, 100)
What's happening here?
You're passing parts.domain (just the root domain, like "paypall") to the typosquatting checker. The returned score and signals get added to the running totals alongside the structural analysis results.
- Save phish_detector.py.
Now let's see if the same domain that slipped through before gets caught this time. What risk level do you expect for paypall.com now?
- Test the typosquatting detection by running:
python3 phish_detector.py paypall.com
You should see a risk score of 35 with risk level MEDIUM, and the signal "1 edit away from 'paypal' (likely typosquatting)" in the report.
- Now test with a domain that uses a "1" instead of an "l" by running:
python3 phish_detector.py paypa1.com
Levenshtein distance catches this too. Swapping "l" for "1" is one edit, so it flags as 1 edit away from "paypal".
- Confirm that legitimate domains stay safe by running:
python3 phish_detector.py google.com
The exact match is skipped (distance of 0), so google.com remains SAFE with no signals.
Getting a NameError or unexpected results?
- If you see NameError: name 'KNOWN_BRANDS' is not defined, make sure your KNOWN_BRANDS list is defined at the top level of the file (after imports, before any function).
- If paypall.com still shows SAFE, check that you added the three new lines calling check_typosquatting() inside analyze_domain().
- help me debug my typosquatting detection
✔️ Awesome, I've got everything!
Great. Double check you've saved phish_detector.py and move on to the next step.
ⓧ I'd like to double check the full code
Your file is too long to show in one block, so scroll up and confirm these pieces are present in order:
- Imports at the top (sys, math, csv, Counter, tldextract, levenshtein_distance).
- KNOWN_BRANDS list with 15 brands (after imports, before any function).
- calculate_entropy() function.
- analyze_structure() function.
- check_typosquatting() function (new in this step).
- analyze_domain() calling both analyze_structure() and check_typosquatting().
- print_report() and main() unchanged at the bottom.
Your tool now catches domains that are one or two edits away from a real brand. But notice something: paypa1.com uses a "1" instead of an "l". Levenshtein distance counts that as one edit, which works. But you have no way to tell the learner WHY it's suspicious. Next, you'll add homoglyph detection that explicitly identifies character-substitution tricks and flags the deception technique by name.
Detect Homoglyph Attacks and Suspicious Patterns
Your typosquatting checker now catches domains that are close to known brands. It correctly flagged paypall.com as 1 edit from "paypal" and scored it as HIGH risk.
But Levenshtein distance only tells you how many characters differ. It does not explain WHY a domain is suspicious. A domain like g00gle.com uses zeros instead of the letter o. Your tool should identify that specific trick by name, and also flag other red flags like suspicious TLDs and keyword stuffing.
In this step, get ready to:
- Add a homoglyph detection system that identifies character substitution tricks.
- Build TLD and keyword checkers to flag common phishing indicators.
- Wire all five checkers into the scoring engine and test against a layered attack.
Add the homoglyph detection system
A homoglyph attack swaps characters with lookalikes. The digit 0 replaces the letter o, the digit 1 replaces l, and multi-character combos like rn mimic m. Your detection system needs a mapping of these fakes to their real counterparts.
- In phish_detector.py, find the KNOWN_BRANDS list. Add the following HOMOGLYPHS dictionary below it:
HOMOGLYPHS = {
"0": "o",
"1": "l",
"3": "e",
"4": "a",
"5": "s",
"7": "t",
"8": "b",
"9": "g",
"@": "a",
"rn": "m",
"vv": "w",
"cl": "d",
}
What does this mapping do?
Each key is the fake character attackers use, and each value is the real character it mimics. Single-character fakes (0 for o) are the most common, but multi-character combos (rn for m) are sneakier because they exploit font rendering.
Now you need a function that uses this mapping. It normalizes the domain by replacing all fakes with reals, then checks if the result matches a known brand.
- Below the check_typosquatting() function, add the check_homoglyphs() function:
def check_homoglyphs(domain):
"""Check for character substitutions that mimic real brands."""
signals = []
score = 0
normalized = domain.lower()
# Replace single-character fakes first
for fake, real in HOMOGLYPHS.items():
if len(fake) == 1 and fake in normalized:
normalized = normalized.replace(fake, real)
# Then replace multi-character fakes
for fake, real in HOMOGLYPHS.items():
if len(fake) > 1 and fake in normalized:
normalized = normalized.replace(fake, real)
# If normalization changed anything, check against brands
if normalized != domain.lower():
for brand in KNOWN_BRANDS:
dist = levenshtein_distance(normalized, brand)
if dist <= 1:
signals.append(
f"Homoglyph attack: '{domain}' looks like '{brand}' "
f"after substitution"
)
score += 40
break
return score, signals
Why replace single characters before multi-character ones?
Order matters. If you replaced cl with d first, you might miss a single-character substitution inside that pair. Processing singles first gives the most accurate normalization.
After normalizing, the function checks if the cleaned-up domain is within 1 edit of any brand. If so, it flags a homoglyph attack worth 40 points.
- Save phish_detector.py.
- Test your new function directly by running:
python3 -c "from phish_detector import check_homoglyphs; print(check_homoglyphs('g00gle'))"
You should see a tuple like (40, ["Homoglyph attack: 'g00gle' looks like 'google' after substitution"]).
Seeing an empty list or ImportError?
- If you get an ImportError, make sure your virtual environment is active. You should see (venv) in your terminal prompt.
- If the result is (0, []), check that your HOMOGLYPHS dictionary uses string keys, not integers. The key must be "0" (string), not 0 (number).
Help me debug my check_homoglyphs function
Add TLD and keyword checkers
Homoglyphs are one signal. Attackers also rely on suspicious top-level domains (like .xyz or .tk) and urgency keywords (like "login" or "verify") to trick users into trusting a link.
- In phish_detector.py, add these two reference lists below the HOMOGLYPHS dictionary:
SUSPICIOUS_TLDS = [
"xyz", "top", "click", "shop", "buzz", "tk", "ml",
"ga", "cf", "gq", "work", "loan", "racing", "win",
]
SUSPICIOUS_KEYWORDS = [
"login", "verify", "secure", "account", "update",
"confirm", "banking", "signin", "support", "help",
"password", "suspend", "alert", "notification",
]
Where do these lists come from?
These TLDs and keywords appear frequently in phishing campaigns tracked by security researchers. TLDs like .tk and .ml offer free registration, making them popular with attackers. Keywords like "login" and "verify" create urgency that overrides a user's caution.
- Below your check_homoglyphs() function, add the TLD checker:
def check_suspicious_tld(suffix):
"""Check if TLD is commonly associated with phishing."""
signals = []
score = 0
if suffix.lower() in SUSPICIOUS_TLDS:
signals.append(f"Suspicious TLD '.{suffix}' (commonly used in phishing)")
score += 15
return score, signals
- Save phish_detector.py and test the TLD checker:
python3 -c "from phish_detector import check_suspicious_tld; print(check_suspicious_tld('xyz'))"
You should see (15, ["Suspicious TLD '.xyz' (commonly used in phishing)"]).
Now add the keyword checker below check_suspicious_tld(). This function scans the full hostname (subdomain + domain) for words that signal phishing intent.
- Add the check_keywords() function:
def check_keywords(domain, subdomain):
"""Check for suspicious keywords in the domain."""
signals = []
score = 0
# Combine subdomain and domain for full scan
full_host = f"{subdomain}.{domain}" if subdomain else domain
found_keywords = [
kw for kw in SUSPICIOUS_KEYWORDS if kw in full_host.lower()
]
if len(found_keywords) >= 2:
signals.append(
f"Multiple suspicious keywords: {', '.join(found_keywords)}"
)
score += 25
elif len(found_keywords) == 1:
signals.append(f"Suspicious keyword: '{found_keywords[0]}'")
score += 10
return score, signals
Why check both subdomain and domain?
Attackers hide keywords in different parts of the URL. A domain like secure.account-verify.login.example.tk packs urgency keywords into the subdomain where casual users might not look closely. Scanning the full hostname catches these regardless of placement.
- Save phish_detector.py and verify the keyword checker:
python3 -c "from phish_detector import check_keywords; print(check_keywords('login-verify', ''))"
You should see a tuple with score 25 and a signal listing both "login" and "verify" as suspicious keywords.
Getting (0, []) from the keyword checker?
- Make sure your SUSPICIOUS_KEYWORDS list is defined before the function. Python reads top to bottom.
- Check that your test string contains exact keyword matches. The checker uses in (substring matching), so login-verify should match both "login" and "verify".
Help me debug my check_keywords function
Wire everything together and test the full suite
Your three new checkers work individually. Now you need to wire them into the analyze_domain() function so every domain gets checked by all five analyzers.
- In phish_detector.py, scroll to the analyze_domain() function.
- Find these lines near the end of the typosquatting check:
typo_score, typo_signals = check_typosquatting(parts.domain)
total_score += typo_score
all_signals.extend(typo_signals)
total_score = min(total_score, 100)
- Add the three new checker calls between the typosquatting block and the total_score = min(total_score, 100) line, so the code looks like this:
typo_score, typo_signals = check_typosquatting(parts.domain)
total_score += typo_score
all_signals.extend(typo_signals)
homo_score, homo_signals = check_homoglyphs(parts.domain)
total_score += homo_score
all_signals.extend(homo_signals)
tld_score, tld_signals = check_suspicious_tld(parts.suffix)
total_score += tld_score
all_signals.extend(tld_signals)
kw_score, kw_signals = check_keywords(parts.domain, parts.subdomain)
total_score += kw_score
all_signals.extend(kw_signals)
total_score = min(total_score, 100)
What does the full pipeline look like now?
- Checker 1 (analyze_structure): flags length, hyphens, subdomains, and entropy.
- Checker 2 (check_typosquatting): flags domains 1-2 edits from a known brand.
- Checker 3 (check_homoglyphs): identifies specific character substitution tricks.
- Checker 4 (check_suspicious_tld): flags TLDs popular with attackers.
- Checker 5 (check_keywords): catches urgency words designed to manipulate users.
Each checker contributes independently to the total score. The min(total_score, 100) cap ensures the score stays on a 0-100 scale.
- Save phish_detector.py.
Before you run the full test, think about this: how many signals do you expect g00gle-login.xyz to trigger? Which checkers will fire?
- Run the full analysis on a layered phishing domain:
python3 phish_detector.py g00gle-login.xyz
You should see multiple signals fire: a homoglyph attack (looks like "google"), a suspicious TLD (.xyz), a suspicious keyword ("login"), and a hyphen. The risk score should be HIGH (70+).
- Now confirm a legitimate domain stays safe:
python3 phish_detector.py amazon.com
You should see no signals detected and a SAFE risk level.
Risk score lower than expected?
- If the homoglyph signal is missing, check that check_homoglyphs(parts.domain) uses parts.domain (not the full URL). The domain part of g00gle-login.xyz is g00gle-login.
- If the TLD signal is missing, verify check_suspicious_tld(parts.suffix) uses parts.suffix (not parts.tld).
- If keyword detection is missing, make sure check_keywords(parts.domain, parts.subdomain) passes BOTH arguments in the correct order.
Help me debug why my risk score is too low
✔️ Awesome, I've got everything!
Great. Double check you have saved phish_detector.py with all three new constants and three new functions wired into analyze_domain().
ⓧ I'd like to double check the full code
Scroll through your file and confirm these sections are present in order:
- Imports (sys, math, csv, Counter, tldextract, levenshtein_distance).
- KNOWN_BRANDS list (15 brands).
- SUSPICIOUS_TLDS list (14 TLDs).
- SUSPICIOUS_KEYWORDS list (14 keywords).
- HOMOGLYPHS dictionary (12 mappings).
- calculate_entropy() function.
- analyze_structure() function.
- check_typosquatting() function.
- check_homoglyphs() function.
- check_suspicious_tld() function.
- check_keywords() function.
- analyze_domain() calling all five checkers.
- print_report() and main().
Placement order matters
The three new constants (SUSPICIOUS_TLDS, SUSPICIOUS_KEYWORDS, HOMOGLYPHS) must appear after KNOWN_BRANDS and before any function definitions. The three new functions must appear before analyze_domain() since it calls them.
Your detector now combines five different signals to build a complete picture of any domain. Next up, you will stress-test it against a full battery of known-good and known-bad domains to make sure it handles edge cases.
Test Against a Battery of Domains
You have tested your detector against individual domains, one at a time. A single test does not prove the tool works across all the phishing patterns it claims to detect.
Real security tools are validated against a mix of known-good and known-bad inputs. In this step, you will add a test battery mode that runs your detector against 10 domains at once and prints a summary table showing which ones are dangerous and why.
In this step, get ready to:
- Add a test battery function with a curated mix of legitimate and phishing domains.
- Run the full battery and validate that safe domains score low and phishing domains score high.
Add the test battery function
Your test battery needs a curated list of domains that exercises every detection path: exact brand matches (should be safe), typosquatting attempts, homoglyph attacks, suspicious TLDs, deep subdomains, and randomly generated strings.
- In phish_detector.py, find the main() function near the bottom of the file.
- Add the following run_test_battery() function directly above main():
def run_test_battery():
"""Run analysis against a battery of test domains."""
test_domains = [
"google.com",
"paypall.com",
"paypa1.com",
"g00gle-login.xyz",
"secure.account-verify.login.example.tk",
"amazon.com",
"arnazon.com",
"microsoft-support-help.click",
"xn7kqbc9df.top",
"github.com",
]
What does this test list cover?
Each domain targets a specific detection path:
- google.com, amazon.com, github.com are legitimate brands that should score SAFE.
- paypall.com and arnazon.com are typosquatting attempts (1-2 edits from real brands).
- paypa1.com and g00gle-login.xyz are homoglyph attacks (character substitutions).
- xn7kqbc9df.top is a high-entropy randomly generated domain with a suspicious TLD.
- Still inside the same run_test_battery() function, add the table printing logic directly below the test_domains list:
print("\n" + "=" * 70)
print(" PHISHING DETECTOR - TEST BATTERY")
print("=" * 70)
print(f"\n {'Domain':<42} {'Score':<7} {'Level':<8} Top Signal")
print(" " + "-" * 66)
for domain in test_domains:
result = analyze_domain(domain)
top_signal = result["signals"][0] if result["signals"] else "None"
if len(top_signal) > 30:
top_signal = top_signal[:27] + "..."
print(
f" {result['domain']:<42} "
f"{result['risk_score']:<7} "
f"{result['risk_level']:<8} "
f"{top_signal}"
)
print("\n" + "=" * 70)
What does this printing logic do?
- The header prints column labels using Python's string formatting (<42 left-aligns text in a 42-character column).
- The loop runs analyze_domain() on each test domain and extracts the top signal (the most important detection reason).
- Long signals are truncated to 30 characters so the table stays readable.
Wire the test battery into main
The main() function already checks for test as the first argument, but it does nothing with it yet. You need to connect it to your new function.
- In main(), find this line:
if target == "test":
pass
- Replace pass with run_test_battery(), so the block looks like this:
if target == "test":
run_test_battery()
- Save phish_detector.py.
Run and validate the test battery
Before you run this, think about what you expect. Which domains should score HIGH? Which should be SAFE? Do you expect any surprises?
- Run the test battery by running this command:
python3 phish_detector.py test
You should see a formatted table with all 10 domains, each showing a risk score, risk level, and the top detection signal.
What should I see?
Legitimate domains like google.com and github.com should show SAFE with no signals. Phishing-style domains like g00gle-login.xyz should show HIGH with multiple triggers (homoglyph, suspicious TLD, keyword).
If any result surprises you, trace through the logic. For example, arnazon.com scores based on Levenshtein distance from "amazon" (only 1 edit: swap the 'm' for 'rn').
Seeing an error when running the test?
- If you get NameError: name 'run_test_battery' is not defined, make sure the function is defined above main() in the file.
- If you see IndentationError, check that the printing code inside run_test_battery() is indented with 4 spaces (matching the test_domains list above it).
- If the table prints but looks misaligned, make sure you copied the f-string formatting exactly (the :<42 and :<7 column widths).
Why isn't my test battery running correctly?
- Confirm that legitimate domains (google.com, amazon.com, github.com) show SAFE or LOW risk levels.
- Confirm that phishing domains (g00gle-login.xyz, secure.account-verify.login.example.tk) show MEDIUM or HIGH risk levels.
✔️ Awesome, I've got everything!
Great. Double check you have saved phish_detector.py with the complete run_test_battery() function and the updated main().
ⓧ I'd like to double check the full code
Your phish_detector.py is now quite long. Scroll through and confirm these sections are present, in order:
- Imports (sys, math, csv, Counter, tldextract, levenshtein_distance).
- Constants (KNOWN_BRANDS, SUSPICIOUS_TLDS, SUSPICIOUS_KEYWORDS, HOMOGLYPHS).
- Functions: calculate_entropy(), analyze_structure(), check_typosquatting(), check_homoglyphs(), check_suspicious_tld(), check_keywords().
- analyze_domain() calling all five checkers.
- print_report() for single-domain output.
- run_test_battery() with 10 test domains and the formatted table loop.
- main() calling run_test_battery() when the argument is test.
Your phishing detector is now fully validated. You have a tool that scores domains from 0 to 100, explains exactly what triggered each signal, and proves itself against a curated set of test cases. Next up: a Secret Mission to add batch processing for real-world SOC workflows.
Secret mission
Batch Scan Domains from a File
Real SOC analysts don't analyze one domain at a time. They process lists of suspicious domains extracted from email logs, DNS queries, or threat feeds. In this mission, you'll add batch scanning that reads domains from a file and exports a full CSV report.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free, open-source tools, so there are no ongoing costs.
Resources you used:
- The phish-detector project folder on your Desktop (includes phish_detector.py, domains.txt, results.csv, and the venv virtual environment).
- Python 3.13 installed via Homebrew.
- pip packages (tldextract, Levenshtein) inside the virtual environment.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your phishing detector or extending it with new detection techniques.
- Your project folder stays on your Desktop at ~/Desktop/phish-detector.
- The virtual environment and installed packages remain inside the project folder.
- Python 3.13 remains available system-wide via Homebrew.
Pause - I'll come back to this later
Shut down any active processes to free up your terminal. Your files stay on disk for when you return.
- If your virtual environment is still active, deactivate it by running:
deactivate
- Close VS Code if you have it open.
- When you return, navigate back to the folder and reactivate:
cd ~/Desktop/phish-detector
source venv/bin/activate
Delete - I don't want to use this again
Remove all project files and optionally uninstall Python 3.13 from your system.
- Deactivate the virtual environment if it is still active:
deactivate
- Delete the entire project folder (this removes all source files, the virtual environment, and installed packages) by running:
rm -rf ~/Desktop/phish-detector
- If you no longer need Python 3.13, uninstall it from Homebrew by running:
brew uninstall python@3.13
Keep Python if you do other projects
If you use Python for anything else on your machine, skip the uninstall step. Removing Python 3.13 may affect other projects that depend on it.
Nice Work!
Nice Work!
You just built a command-line phishing domain detector that analyzes any domain and tells you exactly why it looks dangerous. This is the same kind of heuristic analysis that real SOC analysts use every day to triage suspicious URLs.
You learned how to:
- Built a CLI tool that scores any domain's phishing risk from 0-100 using Shannon entropy, Levenshtein distance, and homoglyph detection to produce explainable signals.
- Implemented real phishing detection techniques including typosquatting detection that catches domains 1-2 edits away from known brands.
- Validated your detector against a test battery of known-good and known-bad domains to confirm accurate risk classification.
- Secret Mission: Added batch processing to scan domain lists from a file and export structured CSV reports.
Ready to quiz yourself?